frontend / apis data fetching / 03_optimistic_updates.md

Optimistic Updates and Rollback

5 min read source

Optimistic Updates and Rollback

TL;DR

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.

Interview Q&A

Q: Why bother with optimistic updates?

A: 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.

Q: When not to do optimistic updates?

A: 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.

Q: The canonical TanStack Query pattern?

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

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.

Q: How do you optimistically add an item to a list?

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

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.

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

A: 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+).

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

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

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.

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

A: 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 ../14_frontend_system_design/ for the collaborative editor design.)

Q: How do you test optimistic updates?

A:

  • 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 / edge cases

  • 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 shapeonSuccess 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.

What a senior is expected to say

  • “The pattern is snapshot → cancel in-flight → write optimistic → on error rollback → on settled reconcile. Missing cancelQueries is the canonical bug.”
  • “I optimistic-update predictable, idempotent operations — likes, toggles, reorder. I do not optimistic-update payments or anything with server-side business rules I can’t replicate.”
  • “On conflict I roll back, refetch server state, and surface the conflict to the user — the optimistic pattern doesn’t fix concurrent edits, it just hides latency.”
  • “For purely-additive optimistic UI, v5’s useMutationState avoids touching the cache at all.”

Cross-references

Further reading