Optimistic Updates and Offline-First Patterns

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
Note: The key to safe optimistic updates is storing the previous state before applying the optimistic change. Capture a snapshot: 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.
Tip: For delete operations, rather than immediately removing the item, first mark it with a “deleting” state that visually indicates it is being removed (grey out, reduced opacity, “Deleting…” overlay), then remove it from the list only after the server confirms. This approach avoids the jarring experience of an item reappearing if the delete fails, while still providing immediate visual feedback that the action was registered.
Warning: Do not apply optimistic updates for operations that have business-critical consequences or are difficult to reverse โ€” financial transactions, sending emails, publishing to external systems. For these, use a confirmed update pattern: show a pending state while the operation runs and only update the UI after success. The UX benefit of optimism must be weighed against the confusion caused by temporary incorrect state if the rollback happens.

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"

🧠 Test Yourself

An optimistic delete removes a task from the UI, then the server returns a 403 Forbidden error. What is the correct rollback strategy, and what information is needed before the delete was applied?