Frontend / APIs & data fetching / 03_optimistic_updates.md

Optimistic Updates and Rollback

Updated 4 interview angles 6 min read source
On this page12
  1. Why bother with optimistic updates?
  2. When not to do optimistic updates?
  3. The canonical TanStack Query pattern?
  4. How do you optimistically add an item to a list?
  5. What if the user fires three optimistic updates in 200ms?
  6. TanStack Query v5 has built-in optimistic state — when do you use it vs hand-rolling?
  7. How do you handle a 409 conflict during an optimistic update?
  8. How do you test optimistic updates?
  9. Gotchas
  10. Related
  11. Further reading
  12. Interview angle

Optimistic Updates and Rollback

An optimistic update writes the expected post-mutation state to the cache before the server confirms it. The UI feels instant; on failure you roll back. Done right, it’s the difference between “snappy” and “feels broken.” Done wrong, it leaks ghost data or papers over real errors. The pattern: snapshot → mutate cache → fire request → on error rollback → on success reconcile.

Why bother with optimistic updates?

Latency hides them when fast (50–100ms), but on flaky networks or slow backends the user perceives every spinner. Optimistic UI removes the spinner entirely for actions where the outcome is predictable (like, favorite, add-to-cart, toggle, reorder list).

Cost: the rollback path needs to exist and be tested.

When not to do optimistic updates?

When the operation can plausibly fail in ways the user must immediately see — payments, irreversible actions, anything where the server applies business rules the client doesn’t fully know. For these, show the spinner and the real result.

The canonical TanStack Query pattern?

Implement four hooks: onMutate (snapshot + write), onError (rollback), onSettled (reconcile), and the mutation function itself.

tsx
const queryClient = useQueryClient();

const toggleLike = useMutation({
  mutationFn: (postId: string) => api.toggleLike(postId),

  onMutate: async (postId) => {
    // 1. Cancel in-flight queries so they don't overwrite our optimistic value
    await queryClient.cancelQueries({ queryKey: ["post", postId] });

    // 2. Snapshot the previous value for rollback
    const previous = queryClient.getQueryData<Post>(["post", postId]);

    // 3. Optimistically update the cache
    queryClient.setQueryData<Post>(["post", postId], (old) =>
      old ? { ...old, liked: !old.liked, likeCount: old.likeCount + (old.liked ? -1 : 1) } : old
    );

    // 4. Return context (used in onError / onSettled)
    return { previous };
  },

  onError: (_err, postId, context) => {
    // Roll back to the snapshot
    if (context?.previous) {
      queryClient.setQueryData(["post", postId], context.previous);
    }
  },

  onSettled: (_data, _err, postId) => {
    // Refetch to reconcile with server truth
    queryClient.invalidateQueries({ queryKey: ["post", postId] });
  },
});

<button onClick={() => toggleLike.mutate(post.id)}></button>

The four steps are the contract. Skipping cancelQueries is the #1 source of “the optimistic value flickered back to the old one” bugs — an in-flight refetch lands after your optimistic write.

How do you optimistically add an item to a list?

Add it with a temporary client id; on server response, replace the temp id with the real one in the cache.

tsx
const addTodo = useMutation({
  mutationFn: (text: string) => api.createTodo(text),

  onMutate: async (text) => {
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    const previous = queryClient.getQueryData<Todo[]>(["todos"]);
    const tempId = `temp-${crypto.randomUUID()}`;

    queryClient.setQueryData<Todo[]>(["todos"], (old = []) => [
      ...old,
      { id: tempId, text, status: "pending", _optimistic: true },
    ]);

    return { previous, tempId };
  },

  onError: (_e, _vars, context) => {
    if (context?.previous) queryClient.setQueryData(["todos"], context.previous);
  },

  onSuccess: (serverTodo, _vars, context) => {
    queryClient.setQueryData<Todo[]>(["todos"], (old = []) =>
      old.map((t) => (t.id === context?.tempId ? serverTodo : t))
    );
  },
});

The _optimistic flag lets the UI dim/italicize pending items so the user can see they aren’t confirmed yet.

What if the user fires three optimistic updates in 200ms?

Two failure modes to address:

  1. Race between optimistic and refetch — fixed by cancelQueries.
  2. Stale snapshot in rollback — the snapshot you saved in the first onMutate is from before the second optimistic write. If the first request fails after the second succeeded, naive rollback restores pre-everything state.

Fix: snapshot the latest cache state at each onMutate, or use a sequence number per mutation, or use TanStack Query’s built-in optimistic state on mutations (v5+).

TanStack Query v5 has built-in optimistic state — when do you use it vs hand-rolling?

v5’s useMutationState + variables lets you derive “in-flight” UI without writing to the cache:

tsx
const variables = useMutationState({
  filters: { mutationKey: ["addTodo"], status: "pending" },
  select: (m) => m.state.variables as string,
});
// render variables[] as optimistic items inline with cached data

Use this when:

  • Adding new items (no need to mutate the cache directly).
  • The optimistic UI is purely additive and easy to derive.

Hand-roll the onMutate/setQueryData pattern when:

  • You’re mutating existing items (toggling, editing).
  • You need the optimistic value to participate in derived selectors elsewhere.

How do you handle a 409 conflict during an optimistic update?

The rollback restores the pre-mutation state, but the server has the new truth (someone else changed the resource). You need to:

  1. Roll back local optimistic state.
  2. Refetch the server state.
  3. Surface the conflict to the user — “this changed since you opened it; reload?”

For collaborative editing, see CRDT/OT — at that point optimistic-with-rollback isn’t enough; you need merge semantics. (See Frontend System Design — Worked Examples for the collaborative editor design.)

How do you test optimistic updates?

  • Unit test the mutation handlers (onMutate, onError, onSettled) with a fake QueryClient.
  • Integration test with MSW: mock the API to return success/error/delay and assert the cache snapshots through the state transitions.
  • E2E with network throttling: confirm the UI is instant on success and rolls back on failure under simulated 3G.

Gotchas

  • Forgetting cancelQueries — your optimistic write gets stomped by an in-flight refetch. Symptom: value flickers back to old.
  • Stale snapshots when multiple mutations stack — rollback restores pre-everything. Snapshot per mutation, not once.
  • Mismatched server response shape — onSuccess replaces the temp item with serverTodo; if serverTodo is missing fields, the UI breaks. Always validate or accept the full server shape.
  • invalidateQueries in onSettled re-fetches even on success — sometimes desired (server is truth), sometimes wasteful (you already wrote the correct value). Use setQueryData from the server response in onSuccess and skip the invalidate when you trust the response.
  • Optimistic updates + page navigation — if the user navigates away mid-mutation, the rollback target may be a component that’s unmounted. The cache rollback still works, but UI feedback (toast on error) needs to be global, not component-local.

Further reading

Interview angle 4

  • “Walk through an optimistic update.” - cancel in-flight queries for that key, snapshot the current cache, write the optimistic value, roll back to the snapshot on error, and reconcile with the server on settled.
    • Follow-up: “Which step do people forget?” - cancelQueries. Without it, a refetch that was already in flight lands after your optimistic write and overwrites it with the old server state, so the UI flickers back and the cause is invisible.
  • “What should never be optimistic?” - anything with server-side rules you cannot replicate on the client, and anything financial. Optimistically showing a payment as complete means showing a lie whenever validation, fraud checks or inventory says otherwise. Likes, toggles and reordering are the safe shape: predictable and idempotent.
  • “Two users edit the same record. Does optimistic UI help?” - no, and it is worth saying plainly. Optimistic updates hide latency, not conflict. On rejection you roll back, refetch the server state, and tell the user what happened - silently reverting is worse than an error, because they saw it work.
  • “Is there a version that does not touch the cache?” - yes, for purely additive UI: read the pending mutations directly with useMutationState and render them alongside the server data. Nothing to roll back, because nothing was written.