Optimistic vs pessimistic updates
Optimistic = update UI immediately, send the request, roll back on failure. Pessimistic = show a loading state, update only on success. Optimistic feels faster but needs rollback paths and clear error UX. Use optimistic for high-confidence, low-stakes mutations (likes, toggles, list reorders); pessimistic for irreversible or expensive ops (payments, deletions, bulk actions).
Two strategies for what the UI shows between "user clicked" and "server responded."
Pessimistic.
async function onSave() {
setIsSaving(true);
try {
await api.save(value);
setSaved(value);
} catch {
showError();
} finally {
setIsSaving(false);
}
}UI doesn't change until the server confirms. Safer; slower-feeling.
Optimistic.
async function onSave() {
const previous = saved;
setSaved(value); // apply locally now
try {
await api.save(value);
} catch {
setSaved(previous); // roll back
showError();
}
}UI updates immediately. Feels instant. Server is "expected to succeed"; failure is exceptional.
When to pick each
| Trait | Optimistic | Pessimistic |
|---|---|---|
| Like / unlike, toggle, pin | ✓ | |
| List reorder, drag-and-drop | ✓ | |
| Add comment / message | ✓ | |
| Toggle a setting | ✓ | |
| Mark task complete | ✓ | |
| Submit a form with validation that the server enforces | depends | ✓ |
| Pay / charge a card | ✓ | |
| Delete data permanently | ✓ (or "soft" optimistic + confirm) | |
| Bulk delete 1000 items | ✓ | |
| Submit a multi-step transaction | ✓ |
Rule of thumb: optimistic when most attempts succeed, failure is recoverable, and the user can see immediately what they intended. Pessimistic when the operation is expensive, irreversible, or has server-side validation that can fail in surprising ways.
In React, two flavors
1. useOptimistic (React 19).
function CommentList({ commentsFromServer }) {
const [optimistic, addOptimistic] = useOptimistic(
commentsFromServer,
(state, newComment) => [...state, { ...newComment, pending: true }]
);
async function submit(text: string) {
addOptimistic({ id: tempId(), text });
await api.postComment(text); // server refreshes the source data
}
return optimistic.map(c => <Comment key={c.id} {...c} dim={c.pending} />);
}Designed for transitions — the optimistic state shows while a transition is pending, then reverts to the canonical state when the transition resolves. Pairs with React Server Actions cleanly.
2. TanStack Query onMutate.
useMutation({
mutationFn: api.like,
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ["post", postId] });
const previous = queryClient.getQueryData(["post", postId]);
queryClient.setQueryData(["post", postId], old => ({ ...old, liked: true, likes: old.likes + 1 }));
return { previous }; // context for rollback
},
onError: (err, postId, ctx) => {
queryClient.setQueryData(["post", postId], ctx.previous);
},
onSettled: (data, err, postId) => {
queryClient.invalidateQueries({ queryKey: ["post", postId] });
},
});Standard pattern: snapshot → apply → on error restore → on settled refetch.
The five things people get wrong
1. Optimistic ID collisions. A new comment created optimistically has no server ID. Use a temporary id (temp-uuid); when the server returns, replace by matching the temp id. Don't shadow another comment's id.
2. Race conditions. User clicks like twice fast. Two mutations in flight. The second's optimistic update overwrites the first's response. Solution: serialize mutations per entity, or use the latest response.
3. Validation mismatches. UI shows the change, then server says "not allowed." User sees their input flicker back to the old value with no warning. UX: explicit error toast + visual highlight of the reverted item.
4. Stale cache after settle. Forgetting to invalidate / refetch means the optimistic value persists past server confirmation; if the server changed something else (timestamps, ranks), UI drifts.
5. Visual jitter. Optimistic add → server replies with re-sorted list → item jumps. Use stable keys and animate position changes, or hold the local order until the user re-sorts.
Senior framing
The interviewer is checking:
- Do you know when each is appropriate?
- Do you handle rollback explicitly?
- Do you handle race conditions and stale caches?
- Do you communicate failure visually so users aren't confused by the revert?
- Have you used the modern primitives (
useOptimistic, TanStack Query) or are you wiring up state by hand?
The "optimistic feels faster, so use it everywhere" answer is junior. The senior answer: use optimistic where the cost of being wrong is a soft revert and a toast; pessimistic where the cost is data loss or user confusion.
Follow-up questions
- •How does `useOptimistic` differ from manual state mirroring?
- •How do you handle the temp-id → server-id replacement?
- •When does optimistic UI hide bugs the user should see?
- •Why does pessimistic still need careful loading-state UX?
Common mistakes
- •Forgetting to roll back on error.
- •Optimistic update without invalidation — cache drifts.
- •Allowing duplicate optimistic submissions.
- •Silent failure — UI reverts without telling the user why.
Performance considerations
- •Optimistic updates make perceived latency near-zero — better than any backend optimization.
- •Snapshots for rollback have memory cost; structural sharing (Immer) helps.
Edge cases
- •Offline submission — queue mutations; resolve when reconnected.
- •Concurrent edits — last optimistic update wins locally, server merge may disagree.
- •Long-running mutations that succeed eventually — show pending state, not just optimistic.
Real-world examples
- •Twitter's like/retweet, Linear's drag reorder, Gmail's archive (with undo banner).
- •Stripe checkout — pessimistic, because money.