Optimistic updates make applications feel instant โ updating the UI immediately when the user takes an action, before the server confirms success. If the server returns an error, the update is rolled back. This pattern โ update immediately, revert on failure โ is the standard approach for mutation-heavy UIs where network latency would otherwise make every action feel sluggish. Combined with offline detection and a queued sync strategy, it produces an application that remains useful even when connectivity is intermittent.
Optimistic Update Patterns
| Pattern | When to Use | Rollback |
|---|---|---|
| Instant update | High confidence the operation will succeed (toggle status, mark complete) | Revert signal to previous value on error |
| Pending state | Medium confidence โ show loading on item while request in-flight | Clear pending state, show error toast |
| Speculative insert | Create shows new item with a temp ID before server responds | Remove temp item, show error |
| Offline queue | Low connectivity โ queue mutations, sync when online | Queue persisted in localStorage; sync on reconnect |
Error Recovery Strategy
| Operation | Optimistic Action | On Error |
|---|---|---|
| Complete task | Set status to ‘completed’ immediately | Revert to previous status, show “Failed to complete” toast |
| Delete task | Remove from list immediately | Re-insert task at original position, show “Failed to delete” toast |
| Create task | Add with temp ID (temp_xyz) and optimistic=true flag |
Remove temp item, show “Failed to create” toast |
| Edit task title | Update title in signal immediately | Revert to previous title, show “Failed to save” toast |
const previous = this._tasks(), apply the optimistic update, then in the error handler: this._tasks.set(previous). This guarantees a clean rollback regardless of how complex the state transformation was. Without the snapshot, a rollback requires reconstructing the previous state from scratch โ which may not always be possible.Complete Optimistic Update Implementation
// โโ Optimistic task store โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@Injectable({ providedIn: 'root' })
export class TaskStore {
private taskService = inject(TaskService);
private toast = inject(ToastService);
private _tasks = signal<Task[]>([]);
readonly tasks = this._tasks.asReadonly();
// โโ Optimistic complete โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
completeOptimistic(id: string): void {
// 1. Capture previous state for rollback
const previous = this._tasks();
const prevStatus = previous.find(t => t._id === id)?.status;
// 2. Apply optimistic update immediately
this._tasks.update(tasks =>
tasks.map(t => t._id === id
? { ...t, status: 'completed', completedAt: new Date().toISOString() }
: t
)
);
// 3. Send to server
this.taskService.complete(id).subscribe({
next: updated => {
// Replace optimistic data with server-confirmed data
this._tasks.update(tasks =>
tasks.map(t => t._id === id ? updated : t)
);
},
error: err => {
// 4. Rollback on failure
this._tasks.set(previous);
this.toast.error(`Failed to complete task: ${err.error?.message ?? 'Try again'}`);
},
});
}
// โโ Optimistic delete โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
deleteOptimistic(id: string): void {
const previous = this._tasks();
const task = previous.find(t => t._id === id);
if (!task) return;
// Optimistic removal
this._tasks.update(tasks => tasks.filter(t => t._id !== id));
this.taskService.delete(id).subscribe({
error: err => {
// Restore the item at its original position
this._tasks.set(previous);
this.toast.error('Failed to delete task');
},
});
}
// โโ Optimistic create with temp ID โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
createOptimistic(dto: CreateTaskDto): void {
const tempId = `temp_${Date.now()}`;
const tempTask: Task = {
_id: tempId,
title: dto.title,
status: 'pending',
priority: dto.priority ?? 'medium',
tags: dto.tags ?? [],
user: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
_optimistic: true, // flag for template to show pending indicator
} as any;
// Optimistic insert
this._tasks.update(tasks => [tempTask, ...tasks]);
this.taskService.create(dto).subscribe({
next: created => {
// Replace temp item with real item from server
this._tasks.update(tasks =>
tasks.map(t => t._id === tempId ? created : t)
);
this.toast.success('Task created');
},
error: err => {
// Remove the temp item
this._tasks.update(tasks => tasks.filter(t => t._id !== tempId));
this.toast.error('Failed to create task');
},
});
}
// โโ Offline queue โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
private queue: Array<{ id: string; action: () => Observable<any> }> = [];
private isOnline = signal(navigator.onLine);
constructor() {
window.addEventListener('online', () => {
this.isOnline.set(true);
this.flushQueue();
});
window.addEventListener('offline', () => this.isOnline.set(false));
}
queueOrExecute(dto: CreateTaskDto): void {
if (this.isOnline()) {
this.createOptimistic(dto);
return;
}
// Offline: queue the operation
const tempId = `temp_${Date.now()}`;
const tempTask = { ...dto, _id: tempId, _queued: true } as any;
this._tasks.update(t => [tempTask, ...t]);
this.queue.push({
id: tempId,
action: () => this.taskService.create(dto).pipe(
tap(created => {
this._tasks.update(tasks =>
tasks.map(t => t._id === tempId ? created : t)
);
}),
catchError(err => {
this._tasks.update(tasks => tasks.filter(t => t._id !== tempId));
this.toast.error('Failed to sync task');
return EMPTY;
})
),
});
}
private flushQueue(): void {
const pending = [...this.queue];
this.queue = [];
from(pending).pipe(
concatMap(item => item.action()),
).subscribe();
}
}
// โโ Template: visual feedback for optimistic state โโโโโโโโโโโโโโโโโโโโโโโโ
// In TaskCardComponent template:
// <div class="task-card"
// [class.task-card--optimistic]="task._optimistic"
// [class.task-card--queued]="task._queued">
// <span *ngIf="task._optimistic" class="badge badge--saving">Saving...</span>
// <span *ngIf="task._queued" class="badge badge--queued">Queued (offline)</span>
// </div>
How It Works
Step 1 โ Snapshot Before Mutate Enables Clean Rollback
Capturing const previous = this._tasks() before applying the optimistic update stores the entire state as a value (arrays and objects in signals are immutable by convention โ update() returns new references). When the error handler calls this._tasks.set(previous), it restores exactly what the user saw before the action. This snapshot approach works regardless of how complex the update transformation was.
Step 2 โ Temporary IDs Mark Unconfirmed Items
Creating a temporary task with a temp_xyz ID allows the template to render it immediately and identify it later. When the server responds with the real task (including a MongoDB ObjectId), the store swaps temp_xyz for the real ID using tasks.map(t => t._id === tempId ? created : t). Components using trackBy: task._id will reuse the same DOM node since the ID changed โ but the item data is updated in place.
Step 3 โ Online/Offline Events Drive Queue Behaviour
The browser fires window.ononline and window.onoffline events when network connectivity changes. The navigator.onLine property gives the current state on load. The offline queue accumulates operations while offline (with optimistic UI updates) and flushes them using concatMap (sequential, ordered) when the online event fires. concatMap ensures operations are applied to the server in the same order the user performed them.
Step 4 โ Visual State Communicates Uncertainty to Users
Optimistic items should be visually distinct from confirmed items. A “Saving…” badge, reduced opacity, or a spinning indicator tells the user “this looks right but is not yet confirmed.” This manages expectations โ if a rollback occurs, the user understands why the item disappeared or changed back. Silently reverting without any visual indication feels like a bug.
Step 5 โ Server Response Replaces Optimistic Data
After a successful server response, always replace the optimistic data with the server-confirmed data. The server may have enriched the data (adding createdAt, default values, computed virtuals) or slightly modified it (trimming the title, normalising tags). Using the server’s response ensures the displayed data exactly matches what is in the database โ preventing subtle inconsistencies from accumulated optimistic assumptions.
Common Mistakes
Mistake 1 โ Not capturing previous state before optimistic update
โ Wrong โ cannot roll back without the previous state:
// Optimistic delete โ no snapshot:
this._tasks.update(tasks => tasks.filter(t => t._id !== id));
// On error: cannot restore item โ it is gone from the signal!
✅ Correct โ snapshot first:
const previous = this._tasks();
this._tasks.update(tasks => tasks.filter(t => t._id !== id));
// On error:
this._tasks.set(previous); // full restore
Mistake 2 โ Using optimistic updates for high-risk operations
โ Wrong โ optimistic payment processing shows “Payment successful” before server confirms:
this._balance.update(b => b - amount); // show new balance immediately
this.paymentService.charge(amount).subscribe({
error: () => this._balance.update(b => b + amount) // rollback
// Rollback after "Payment successful" is extremely confusing UX
});
✅ Correct โ confirmed update for financial or irreversible operations:
this._processing.set(true);
this.paymentService.charge(amount).subscribe({
next: () => { this._balance.update(b => b - amount); },
error: () => { this.toast.error('Payment failed') },
complete: () => this._processing.set(false),
});
Mistake 3 โ Not providing visual feedback for queued offline operations
โ Wrong โ offline task looks identical to confirmed task โ user confused on reconnect:
<app-task-card [task]="task"></app-task-card>
<!-- No indication that this task has not been saved to the server -->
✅ Correct โ show offline/queued state visually:
<app-task-card [task]="task" [class.task--queued]="task._queued"></app-task-card>
<!-- CSS: .task--queued { opacity: 0.7; border-left: 3px solid orange; } -->
Quick Reference
| Task | Pattern |
|---|---|
| Snapshot for rollback | const prev = this._tasks() before mutation |
| Rollback on error | error: () => this._tasks.set(prev) |
| Optimistic insert | Add item with temp_${Date.now()} ID, replace on success |
| Optimistic delete | Filter out item, restore previous on error |
| Optimistic update | Map to new value, map back on error |
| Offline detection | navigator.onLine + window.addEventListener('online/offline', ...) |
| Flush queue in order | from(queue).pipe(concatMap(op => op.action())) |
| Visual pending state | [class.task--saving]="task._optimistic" |