Rolling Back Optimistic Updates on Error

Optimistic updates paint the mutation’s expected result into the cache before the server has acknowledged it, so the UI feels instant. The failure mode is what happens when the request rejects: without a rollback, the cache keeps the optimistic write forever, and the user sees a “liked” post or a “sent” message that the server never actually recorded. This page is a concrete recipe within Mutation Sync & Rollback, and it walks through the exact TanStack Query v5 onMutate / onError / onSettled lifecycle that makes rollback deterministic. If your failures are network-partition failures rather than server rejections, the companion pattern in Syncing Offline Mutations on Reconnect queues the mutation instead of rolling it back.

The root cause of most broken rollbacks is a missing or mis-ordered snapshot: teams call setQueryData optimistically but never capture the previous value, or they capture it after an in-flight refetch has already mutated the cache. The fix is a strict four-phase context handoff between the mutation callbacks.

Diagnostic Checklist

Before writing any code, confirm your symptoms match this failure mode:

  • A failed mutation leaves the optimistic value on screen — the toggle stays flipped, the new row stays in the list — until a manual refresh.
  • The optimistic value briefly reverts to the old server value mid-flight, then snaps back to the optimistic value, because a background refetch resolved after your write.
  • onError fires but the UI does not revert, because context is undefined or the snapshot was never returned from onMutate.
  • Two rapid clicks produce a rollback to a state that still contains the other click’s optimistic write.
  • TanStack Query DevTools shows the query as fetching at the exact moment your optimistic setQueryData runs.

Optimistic update and rollback lifecycle in TanStack Query v5 A timeline showing onMutate cancelling in-flight queries and snapshotting the cache before an optimistic write, then two branches: onError restores the snapshot, onSuccess keeps the write, and both converge on onSettled which invalidates the query to reconcile with the server. Mutation lifecycle (useMutation) onMutate 1. await cancelQueries 2. getQueryData (snapshot) 3. setQueryData (optimistic) 4. return { previous } mutationFn server request onError (reject) setQueryData(key, context.previous) onSuccess (resolve) keep optimistic write onSettled (always) invalidateQueries({ queryKey }) reconcile cache with server Why cancelQueries runs first An in-flight refetch would resolve AFTER the optimistic write and overwrite it with pre- mutation server data. cancelQueries aborts it so setQueryData is the last write before onSettled.
The context object returned from onMutate is the snapshot; onError consumes it to revert, and onSettled invalidates so the final state is authoritative.

Step-by-Step Implementation

Step 1 — Cancel in-flight queries before touching the cache

The first line of onMutate must await queryClient.cancelQueries for the key you are about to mutate. This aborts any background refetch already in flight so it cannot resolve after your optimistic write and clobber it with stale server data.

// hooks/use-toggle-like.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

interface Post {
  id: string;
  liked: boolean;
  likeCount: number;
}

const postKey = (id: string) => ['post', id] as const;

async function toggleLike(id: string): Promise<Post> {
  const res = await fetch(`/api/posts/${id}/like`, { method: 'POST' });
  if (!res.ok) throw new Error('Toggle failed');
  return res.json();
}

Cache Behavior Analysis. Defining a stable postKey factory means cancelQueries, getQueryData, setQueryData, and invalidateQueries all address the exact same cache entry. A mismatched key here is the single most common cause of a snapshot that captures undefined and a rollback that silently no-ops.


Step 2 — Snapshot, write, and hand off the context

Inside onMutate, cancel first, read the current cache with getQueryData, apply the optimistic write with a functional setQueryData updater, then return the snapshot. Whatever you return becomes the context argument passed to onError and onSettled.

export function useToggleLike(id: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: () => toggleLike(id),
    async onMutate() {
      // 1. Stop in-flight refetches from overwriting the optimistic write
      await queryClient.cancelQueries({ queryKey: postKey(id) });

      // 2. Snapshot the exact value we will restore on failure
      const previous = queryClient.getQueryData<Post>(postKey(id));

      // 3. Optimistically update — functional updater avoids stale closures
      queryClient.setQueryData<Post>(postKey(id), (old) =>
        old
          ? { ...old, liked: !old.liked, likeCount: old.likeCount + (old.liked ? -1 : 1) }
          : old,
      );

      // 4. Hand the snapshot to onError/onSettled via context
      return { previous };
    },
    // onError / onSettled defined in the next steps
  });
}

Cache Behavior Analysis. getQueryData returns the current cached value by reference — it is a shallow read, so the snapshot shares nested object references with the live cache. Because the setQueryData updater spreads into a new object rather than mutating old, the snapshot’s reference stays pointed at the pre-mutation object and remains a valid restore target. Returning it from onMutate is what threads it into the context parameter of the later callbacks.


Step 3 — Restore the snapshot in onError

onError receives (error, variables, context). Guard against a missing context (an exception thrown inside onMutate before the return leaves context undefined) and restore the snapshot verbatim.

    onError(_error, _variables, context) {
      // Only restore if onMutate completed and produced a snapshot
      if (context?.previous !== undefined) {
        queryClient.setQueryData(postKey(id), context.previous);
      }
    },

Cache Behavior Analysis. Restoring writes the snapshot back as the query’s data without triggering a network request. Any component subscribed to postKey(id) re-renders with the reverted value on the next microtask. Because you cancelled the in-flight query in Step 2, there is no pending fetch left to resolve and re-corrupt the cache after this restore.


Step 4 — Reconcile with the server in onSettled

onSettled runs after both success and error. Invalidate the key so TanStack Query refetches the authoritative server state, correcting any drift between your optimistic guess and what the server actually persisted.

    onSettled() {
      // Runs on success AND error — reconcile cache with the server of record
      queryClient.invalidateQueries({ queryKey: postKey(id) });
    },
  });
}

Cache Behavior Analysis. invalidateQueries marks the entry stale and triggers a refetch for any active observer. On success this replaces the optimistic guess (e.g. likeCount + 1) with the server’s real count, which may differ if another user liked the post concurrently. On error it fetches fresh data on top of the just-restored snapshot, so even a partial server write is reconciled. This is why onError restore and onSettled invalidation are complementary rather than redundant.

  • Always await cancelQueries — an un-awaited call does not block the in-flight refetch and reintroduces the overwrite race.
  • Take the snapshot with getQueryData after cancelling, never before, so it reflects the true pre-mutation baseline.
  • Use functional setQueryData updaters, not a captured value, so concurrent mutations compose instead of clobbering.
  • Keep onSettled invalidation even when onError restores; set staleTime on the query so the reconciling refetch is skipped when data is still fresh.

Edge Cases and Gotchas

Concurrent mutations on the same key

If two toggleLike calls fire in quick succession, each onMutate snapshots at its own start time. Should the second one fail, its context.previous already contains the first mutation’s optimistic write, so restoring it does not un-apply the first. Rely on the onSettled invalidation to converge, or serialize with a mutation scope:

useMutation({
  scope: { id: `post-${id}` }, // mutations sharing a scope id run serially
  mutationFn: () => toggleLike(id),
});

List caches versus detail caches

When the same entity appears in a list query (['posts']) and a detail query (['post', id]), a rollback must restore both snapshots. Capture and restore each key explicitly, and return both in the context object; a single-key rollback leaves the list showing the optimistic value while the detail reverts.

Snapshot of undefined

If the query has never been fetched, getQueryData returns undefined. Writing that back in onError deletes the cache entry rather than restoring a value. Guard the restore with context?.previous !== undefined (as in Step 3) so a never-fetched query is left untouched rather than blanked.


Common Pitfalls and Resolutions

Observable Issue Root Cause Diagnostic Resolution
Optimistic value briefly reverts then reappears mid-flight A background refetch was in flight and resolved after setQueryData, overwriting it with pre-mutation server data await queryClient.cancelQueries({ queryKey }) as the first statement in onMutate before reading or writing the cache
onError fires but UI does not revert onMutate did not return the snapshot, so context is undefined and the restore is skipped Return { previous } from onMutate and restore with context?.previous guarded against undefined
Cache stays wrong after a successful mutation No onSettled invalidation; the optimistic guess diverged from the server’s actual persisted value Add invalidateQueries({ queryKey }) in onSettled so the authoritative server state reconciles after both success and error
Rapid double-clicks roll back to a partially-optimistic state Concurrent mutations each snapshot at their own start, so a later snapshot embeds an earlier optimistic write Set a shared mutation scope.id to serialize, or lean on onSettled invalidation to converge to server truth

Frequently Asked Questions

Why does cancelQueries have to run before setQueryData in onMutate?

A background refetch that is already in flight when the mutation starts will resolve after your optimistic setQueryData write and overwrite it with server data that predates the mutation — the user sees the value flip to optimistic, then snap back to old, then to optimistic again. Awaiting cancelQueries aborts those requests so your setQueryData is the last write to touch the cache until the mutation itself resolves. Skipping the await reintroduces the race because the cancellation has not completed before you write.

What happens if two optimistic mutations run concurrently on the same key?

Each onMutate snapshots the cache at its own start time. If the second mutation fails, its context.previous already contains the first mutation’s optimistic write, so restoring that snapshot does not undo the first change. Prefer functional setQueryData updaters so the writes compose, rely on the onSettled invalidation to converge on server truth, or give the mutations a shared scope.id so TanStack Query runs them serially and each snapshot reflects the previous mutation’s committed state.

Do I still need onSettled invalidation if onError already restores the snapshot?

Yes. onError only reverts to the pre-mutation state, which is not the same as the authoritative post-mutation server state. onSettled runs after both success and error and invalidates the key so the cache reconciles with the server — correcting a partial write, a server-side transformation, or concurrent changes from another client. On success it replaces your optimistic guess with the real value; on error it fetches fresh data on top of the restored snapshot. The two mechanisms cover different failure surfaces.


  • Mutation Sync & Rollback — the parent reference covering snapshot strategies, atomic error boundaries, and rollback semantics across TanStack Query, SWR, Apollo, and RTK Query.
  • Syncing Offline Mutations on Reconnect — the companion pattern for failures caused by lost connectivity, where the mutation is paused and replayed rather than rolled back.
  • Cache Invalidation & Server Synchronization — the foundational pillar covering invalidation timing, refetch strategies, and the server-synchronization guarantees that rollback depends on.
  • Background Refetch Strategies — how staleTime and background refetch interact with the onSettled invalidation that reconciles the cache after a mutation settles.