Waiting for the server before changing the screen is honest, but slow: a like that takes 400 ms to appear feels broken. An optimistic update changes the interface immediately, assumes the request will succeed and undoes the change if it doesn't.
The idea fits in one sentence. The details are where apps break: what exactly you restore on failure, what happens when two answers arrive in the wrong order, and how the server avoids applying the same action twice when you retry.
The pattern: remember, apply, confirm or undo
Three steps. Remember the current value, change it locally, send the request. On success the server's answer replaces your guess — it may differ (an id, a timestamp, trimmed text). On failure restore what you remembered and tell the user: a value that silently jumps back looks like a bug.
import { Service, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TodoApi, type Todo } from './todo-api';
@Service()
export class TodoStore {
private readonly api = inject(TodoApi);
readonly todos = signal<readonly Todo[]>([]);
readonly error = signal<string | null>(null);
async toggle(id: string): Promise<void> {
const before = this.todos().find((t) => t.id === id);
if (!before) return;
this.patch(id, { done: !before.done }); // 1. the screen changes NOW
try {
const saved = await firstValueFrom(this.api.update(id, { done: !before.done }));
this.patch(id, saved); // 2. the server's version wins (timestamps, normalised text...)
} catch {
this.patch(id, { done: before.done }); // 3. undo ONLY this item...
this.error.set(`Couldn't save “${before.title}”. Undone.`); // ...and say so
}
}
private patch(id: string, change: Partial<Todo>): void {
this.todos.update((list) => list.map((t) => (t.id === id ? { ...t, ...change } : t)));
}
}The interface
Saved on the server
- Write the release notes
- Review the pull request
- Update the dependencies
- Fix the flaky test
- feedback after
- —
- requests
- 0
- failed
- 0
- interface ≠ server
- 0
Undo what you changed, not the whole list
Saving a snapshot of the whole list is the most tempting version and it's wrong as soon as two requests are in flight. The snapshot taken by the first request doesn't contain the second change; when the first one fails, restoring it also erases a change the server has already saved. The scenario in the demo does exactly that: the interface and the server end up disagreeing, with nothing left to fix it. Undo per item, or apply the inverse operation.
// ✗ A snapshot of the WHOLE list also captures the other changes still in flight
async toggle(id: string) {
const snapshot = this.todos(); // taken before the user's next click...
this.patch(id, { done: !this.find(id).done });
try {
await firstValueFrom(this.api.update(id, ...));
} catch {
this.todos.set(snapshot); // ...so this also undoes a change the server has ALREADY saved
}
}Optimistic creation needs one more step. The new item doesn't have a real id yet, so give it a temporary one and replace it when the server answers. Until then, edits to that item have to wait or use the temporary id, which the server doesn't know.
async add(title: string): Promise<void> {
const temp: Todo = { id: `tmp-${crypto.randomUUID()}`, title, done: false };
this.todos.update((list) => [...list, temp]);
try {
const saved = await firstValueFrom(this.api.create({ title }));
// the temporary id disappears: every later edit uses the real one
this.todos.update((list) => list.map((t) => (t.id === temp.id ? saved : t)));
} catch {
this.todos.update((list) => list.filter((t) => t.id !== temp.id));
this.error.set(`“${title}” was not added.`);
}
}Answers that arrive in the wrong order
Switch a setting on, off and on again, quickly. Three requests leave, and nothing guarantees the order in which their answers come back. If every answer overwrites the state, the winner is the last answer to arrive — not the last choice the user made.
Every click sends a request. Its bar ends when the answer arrives.
- You chose
- off
- Last answer wins
- off …
- Newest request only
- off …
“Last answer wins” got it wrong 0 times out of 0.
Two fixes, for two different problems. Newest only: every request gets a number and only the answer to the newest one may write — that fixes the interface. But the server may also have received the requests out of order. If the final state on the server matters, serialise the writes with concatMap (one request at a time, in the order of the clicks), or send a version the server checks. For searches, where you only care about the last one, switchMap cancels the old ones; see higher-order operators.
private latest = 0;
readonly enabled = signal(false); // what the user sees (optimistic)
private readonly confirmed = signal(false); // what the server last confirmed
async setNotifications(on: boolean): Promise<void> {
const id = ++this.latest;
this.enabled.set(on);
try {
const saved = await firstValueFrom(this.api.saveSettings({ notifications: on }));
this.confirmed.set(saved.notifications);
// an older answer that arrives late must not overwrite a newer choice
if (id === this.latest) this.enabled.set(saved.notifications);
} catch {
if (id === this.latest) this.enabled.set(this.confirmed());
}
}Idempotency: one action, applied once
Optimistic interfaces retry, and here's the trap: the request reached the server, the order was created, and only the answer got lost. A retry creates a second order. The fix is an Idempotency-Key: a UUID generated once per user action and sent with every retry. The server remembers the keys it has processed and, for a repeat, returns the original result instead of doing the work again.
import { HttpClient, HttpContext, HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { retry } from 'rxjs';
export const IDEMPOTENCY_KEY = new HttpContextToken<string | null>(() => null);
/** The key travels in the request's context and becomes a header. */
export const idempotencyInterceptor: HttpInterceptorFn = (req, next) => {
const key = req.context.get(IDEMPOTENCY_KEY);
return next(key ? req.clone({ setHeaders: { 'Idempotency-Key': key } }) : req);
};
// One key per USER ACTION, reused by every retry of that action.
placeOrder(order: Order) {
const key = crypto.randomUUID();
return this.http
.post<OrderConfirmation>('/api/orders', order, {
context: new HttpContext().set(IDEMPOTENCY_KEY, key),
})
.pipe(retry({ count: 3, delay: 1000 })); // same observable → same context → same key
}The key belongs to the user's action, not to the HTTP request: generate it when the user presses the button, not in an interceptor that would make a new one on every retry. Retries themselves are covered in HttpClient and interceptors.
When YES, when NO
- Frequent, cheap actions that almost always succeed: likes, ticks, marking as read.
- Changes that are easy to undo: reordering, renaming, the quantity in a cart.
- Interfaces where latency is felt on every click: lists, boards, editors.
- Payments and anything with money: the user needs a real confirmation.
- Irreversible actions: sending an email, deleting for good, publishing.
- Validation that often fails on the server (a username that's taken): you'd be undoing all the time.
Common pitfalls
- Silent rollback. The tick disappears with no message; the user thinks the app is broken or doesn't notice and moves on.
- A snapshot of the whole list. It also undoes other changes still in flight, including some the server already saved.
- The last answer wins. An old answer arriving late overwrites the user's newest choice. Number the requests.
- Retrying without an idempotency key. A lost answer turns into a duplicate order.
- Never reconciling with the server. Temporary ids that are never replaced, normalised text that never comes back: the interface slowly drifts away from the data.
Cheat sheet
optimistic update snapshot → apply now → send → server version | roll back + message
roll back only what this request changed (the item / the inverse operation)
not the whole list a list snapshot also undoes other requests still in flight
server's answer replaces the local guess (ids, timestamps, normalised text)
temporary ids tmp-uuid until "create" answers, then swap for the real id
out-of-order answers id per request: only the newest may write (newest-only)
writes in order concatMap: one at a time, in the order of the clicks
reads switchMap: cancel the old search, keep the new one
retries Idempotency-Key = one UUID per user action, same on every retry
never optimistic payments, irreversible actions, validation that often fails
rollback without a message = a UI that looks broken