Mutation Sync & Rollback
When a user clicks Save, Delete, or Submit, your application makes a promise: the UI reflects that action instantly. If the server rejects the request, that promise must be cleanly retracted — returning the cache to exactly the state it was in before. Broken rollback logic is one of the most common sources of stale or ghost data in production React applications.
This guide is part of Cache Invalidation & Server Synchronization and covers the full mutation lifecycle: pre-mutation snapshotting, optimistic writes, error-boundary rollback, and post-settlement reconciliation. If your team is also evaluating when to use stale-while-revalidate or need targeted invalidation via tag-based invalidation systems, those topics complement what is covered here.
Diagnostic checklist
Use this checklist to confirm that mutation rollback is the failure mode you are debugging:
- The UI shows an updated value after a user action, then reverts — but the revert is delayed, incomplete, or shows the wrong previous value.
- After a failed mutation, a subsequent page reload shows the correct server state, proving the optimistic write was not rolled back.
- Two rapid mutations on the same entity produce a cache state that does not match either the first or second server response.
- Deleted entities remain visible in lists after a failed
DELETErequest. - A mutation that succeeds on retry leaves the cache in a state that reflects the first (failed) attempt’s optimistic write.
Prerequisites
Before working through the implementations below, you should understand:
- How cache-layer architecture separates UI state from server state, and why mutation context belongs in the cache layer, not component state.
- The difference between client-vs-server state boundaries — optimistic updates straddle this boundary by design, and that is where most rollback bugs originate.
- How entity mapping strategies assign stable IDs to cached objects, because rollback mechanics must target specific normalized entities, not broad query keys.
The mutation rollback lifecycle
Before diving into framework-specific implementations, the diagram below shows the deterministic state machine that every mutation must follow. Each transition is a discrete point where things can go wrong.
The snapshot captured in step 2 is the entire rollback mechanism — lose it, and a deterministic revert is impossible.
Implementation 1 — TanStack Query v5 (React)
TanStack Query’s onMutate / onError / onSettled lifecycle is the most explicit implementation of the snapshot-rollback pattern. The context object returned from onMutate is the only safe channel for passing the snapshot to onError.
Steps
- Call
cancelQueriesbefore reading the snapshot. Outstanding refetches running concurrently will overwrite the optimistic state if not cancelled. - Read the current cache value with
getQueryDataand store it in a context object. - Apply the optimistic payload with
setQueryData. Structural sharing (structuralSharing: true, the default) means unchanged references are reused, so only components that depend on changed fields re-render. - Return the context from
onMutate. TanStack Query passes it to bothonErrorandonSettled. - In
onError, restore the snapshot. InonSettled, callinvalidateQueriesregardless of outcome to trigger a background refetch that reconciles with the server.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Item {
id: string;
name: string;
status: 'active' | 'archived';
}
type MutateItemInput = Pick<Item, 'id'> & Partial<Omit<Item, 'id'>>;
export function useUpdateItem() {
const queryClient = useQueryClient();
return useMutation<Item, Error, MutateItemInput, { previousItems: Item[] | undefined }>({
mutationFn: (payload) =>
fetch(`/api/items/${payload.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then((res) => {
if (!res.ok) throw new Error(`Server rejected mutation: ${res.status}`);
return res.json() as Promise<Item>;
}),
onMutate: async (payload) => {
// Step 1: cancel in-flight refetches to prevent a stale response
// overwriting the optimistic state after we write it.
await queryClient.cancelQueries({ queryKey: ['items'] });
// Step 2: snapshot the current cache value — this is the restore point.
const previousItems = queryClient.getQueryData<Item[]>(['items']);
// Step 3: apply the optimistic write. structuralSharing (default true)
// reuses unchanged object references, minimising re-renders.
queryClient.setQueryData<Item[]>(['items'], (old) =>
old
? old.map((item) =>
item.id === payload.id ? { ...item, ...payload } : item
)
: []
);
// Step 4: return context so onError and onSettled can access the snapshot.
return { previousItems };
},
onError: (_error, _payload, context) => {
// Step 5a: deterministic rollback — restore exactly what was there before.
if (context?.previousItems !== undefined) {
queryClient.setQueryData(['items'], context.previousItems);
}
},
onSuccess: (serverItem) => {
// Commit the authoritative server response over the optimistic write.
// This handles shape mismatches between what we predicted and what the
// server returned (e.g. a server-generated updatedAt timestamp).
queryClient.setQueryData<Item[]>(['items'], (old) =>
old
? old.map((item) => (item.id === serverItem.id ? serverItem : item))
: [serverItem]
);
},
onSettled: () => {
// Step 5b: trigger background reconciliation regardless of outcome.
// gcTime (default 5 min) keeps the invalidated data available until
// the background refetch completes, preventing a loading flash.
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});
}
Cache Behavior Impact: cancelQueries sets a cancellation flag that causes any in-flight observer to discard its response when it arrives. setQueryData writes synchronously to the query cache and notifies all active observers in a single batch update. The structural sharing algorithm performs a deep equality check on the old and new values, reusing object references where the data is unchanged — so a list of 100 items where only one changed triggers a re-render only in the component bound to that item, not all 100. When invalidateQueries fires in onSettled, TanStack Query marks the cache entry as stale and immediately refetches if any observer is currently mounted, without forcing a loading state on the observer (the stale data remains visible during the background fetch).
Configuration trade-offs for this section:
- Setting
staleTime: Infinityon the['items']query prevents automatic background refetches, which makes theonSettledinvalidation the sole reconciliation mechanism. Use this in write-heavy UIs where background refetches create noise. gcTime(formerlycacheTime) controls how long the invalidated cache entry survives without an active observer. The default 5 minutes is fine for most cases; reduce it for large entity lists where heap pressure matters.structuralSharing: falsedisables reference reuse and may be warranted if your entities contain non-serialisable values, but it increases re-renders and should be avoided on high-frequency lists.- The
retryoption onuseMutationdefaults to0— mutations are not retried automatically. If you enable retries, ensureonMutateand rollback logic is idempotent, becauseonMutatefires once (not per retry) butonErrorfires after the final retry.
Implementation 2 — Apollo Client v3 with optimisticResponse
Apollo’s InMemoryCache uses normalized entity storage keyed by __typename:id. The optimisticResponse field is the idiomatic way to apply an optimistic write that Apollo can automatically revert — unlike cache.modify, which commits immediately and cannot be rolled back by the library.
Steps
- Provide
optimisticResponsethat matches the exact shape of the mutation’s expected server response. Apollo writes this to a temporary optimistic layer inInMemoryCache. - Use the
updatecallback to apply additional derived cache changes (updating a list, writing to a related query). These also go into the optimistic layer. - If the mutation succeeds, Apollo promotes the server response from the optimistic layer to the base cache and calls
updateagain with the real data. - If the mutation fails, Apollo discards the optimistic layer, automatically reverting all writes made under
optimisticResponse. No manual rollback needed.
import { useMutation, gql, useApolloClient } from '@apollo/client';
const UPDATE_TASK_MUTATION = gql`
mutation UpdateTask($id: ID!, $title: String!, $completed: Boolean!) {
updateTask(id: $id, title: $title, completed: $completed) {
id
title
completed
updatedAt
}
}
`;
interface Task {
id: string;
title: string;
completed: boolean;
updatedAt: string;
__typename: 'Task';
}
interface UpdateTaskVariables {
id: string;
title: string;
completed: boolean;
}
export function useUpdateTask() {
const [updateTask, result] = useMutation<
{ updateTask: Task },
UpdateTaskVariables
>(UPDATE_TASK_MUTATION, {
// Apollo writes this to the optimistic layer in InMemoryCache immediately.
// If the mutation fails, this entire layer is discarded — automatic rollback.
optimisticResponse: ({ id, title, completed }) => ({
updateTask: {
__typename: 'Task',
id,
title,
completed,
// Predict a timestamp so the UI doesn't flicker to "unknown" while waiting.
updatedAt: new Date().toISOString(),
},
}),
// update runs twice: once with the optimistic data, once with the real response.
// Both runs are safe — Apollo handles the promotion/revert internally.
update(cache, { data }) {
if (!data?.updateTask) return;
// cache.identify derives the normalized cache key: "Task:abc123"
const cacheId = cache.identify(data.updateTask);
// Field-level write into the normalized entity.
// This is safe here because it runs inside Apollo's optimistic layer.
cache.modify({
id: cacheId,
fields: {
title: () => data.updateTask.title,
completed: () => data.updateTask.completed,
updatedAt: () => data.updateTask.updatedAt,
},
});
},
onError(error) {
// Apollo has already reverted the optimistic layer at this point.
// Log or surface the error; no cache cleanup needed.
console.error('UpdateTask mutation failed:', error.message);
},
});
return { updateTask, ...result };
}
Cache Behavior Impact: Apollo maintains two cache layers: the base cache and an optimistic layer stack. When optimisticResponse is provided, Apollo writes a diff to the top of that stack and recomputes all affected normalized entities by merging base + optimistic diffs. Active queries reading those entities re-render with the merged (optimistic) values. On mutation success, Apollo removes the optimistic diff and writes the real server response to the base cache, triggering another re-render. On error, Apollo pops the optimistic diff without writing to the base cache — components re-render back to the base values with zero manual intervention. The update function participates in both the optimistic and real write phases, meaning any list modifications inside update are also reverted on failure.
Configuration trade-offs for this section:
- Do not use
cache.modifyalone for writes you expect to be reverted on error — it bypasses the optimistic layer and commits directly to the base cache. Paircache.modifywithoptimisticResponseor usecache.writeFragment/cache.writeQueryinside theupdatefunction instead. - Apollo’s
InMemoryCachetypePoliciescan define customkeyFieldsfor types without anidfield. Ifcache.identifyreturnsundefined,updatesilently does nothing — verify__typenameis present on all mutation response shapes. fetchPolicy: 'network-only'on dependent queries prevents Apollo from serving stale cache data while the optimistic layer is active, which matters for queries that display aggregate or computed values derived from mutated entities.
Implementation 3 — SWR v2 with mutate
SWR’s bound mutate function supports inline optimistic updates via the optimisticData option and rollbackOnError: true for automatic revert. This is the most concise implementation across the three libraries, at the cost of less control over normalization.
Steps
- Obtain the bound mutate function from
useSWRalongside the data. - Call
mutate(updateFn, { optimisticData, rollbackOnError: true, revalidate: true }). TheoptimisticDatavalue (or function) is applied to the local cache immediately. rollbackOnError: trueinstructs SWR to restore the previous cache value ifupdateFnthrows.revalidate: true(the default) triggers a background refetch after the mutation settles, regardless of outcome.
import useSWR from 'swr';
interface User {
id: string;
email: string;
role: 'admin' | 'member';
}
const fetcher = (url: string): Promise<User> =>
fetch(url).then((res) => {
if (!res.ok) throw new Error('Fetch failed');
return res.json();
});
export function useUser(userId: string) {
const { data, error, mutate } = useSWR<User>(`/api/users/${userId}`, fetcher, {
// Keep stale data visible during background revalidation — no loading flash.
revalidateOnFocus: false,
});
const updateRole = async (newRole: User['role']) => {
if (!data) return;
await mutate(
// The async function that performs the actual network request.
// If this throws, SWR rolls back to the previous cache value.
async (currentData) => {
const response = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
});
if (!response.ok) throw new Error(`Role update failed: ${response.status}`);
return response.json() as Promise<User>;
},
{
// Apply this value to the cache immediately, before the network request.
optimisticData: { ...data, role: newRole },
// Restore the original cache value if the async function throws.
rollbackOnError: true,
// After settling (success or error), trigger a background revalidation.
revalidate: true,
// Suppress the automatic loading state during the mutation.
populateCache: true,
}
);
};
return { user: data, error, updateRole };
}
Cache Behavior Impact: SWR stores a snapshot of the current cache value before applying optimisticData. When rollbackOnError: true, SWR holds this snapshot in a closure and calls mutate(snapshot, { revalidate: false }) internally if the mutation function rejects — effectively a library-managed rollback. The bound mutate function from useSWR scopes the cache key automatically to /api/users/${userId}, so the rollback is always entity-scoped. Unlike TanStack Query, SWR does not provide a direct mechanism for cancelling in-flight revalidations before the optimistic write, which means a concurrent background refetch triggered by revalidateOnFocus could overwrite the optimistic state. Disable revalidateOnFocus or call the global mutate to cancel before writing.
Configuration trade-offs for this section:
populateCache: true(the default) writes the mutation response into the SWR cache so the nextuseSWRcall returns it immediately without a network fetch. SetpopulateCache: falseif the mutation response does not match the query response shape.revalidate: falseskips the post-mutation background fetch. Use this only when you are confident the mutation response is the authoritative final state — for example, a DELETE that removes the entity entirely.- SWR does not expose a
gcTimeequivalent. Invalidated keys are removed from the cache only when there are no active subscribers. For large lists, this means the snapshot held in memory for rollback could persist longer than expected. optimisticDataaccepts either a value or a function(currentData) => newData. Prefer the function form when the optimistic state depends on the current cache value, to avoid closure staleness bugs in rapid sequential mutations.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| UI reverts to an intermediate state rather than the pre-mutation value after rollback | onMutate snapshot was captured after a concurrent mutation already modified the cache. Snapshots from two overlapping onMutate calls overwrite each other, and the second rollback restores the wrong value. |
Implement a mutation queue or use a useRef map keyed by entity ID to track per-entity snapshots. For TanStack Query, set mutationFns to queue via a serial Promise chain using a shared Mutex. |
| Ghost entities remain visible in lists after a failed DELETE mutation | The list query key was not included in the snapshot or rollback scope. The mutation targeted an entity query key but the list query key contains the now-deleted item’s ID. | Snapshot both the entity key ['items', id] and the list key ['items'] in onMutate. In onError, restore both. Use tag-based invalidation systems to group list and entity keys under a shared tag. |
| Stale value flashes briefly after a successful mutation before settling on the correct server data | onSuccess does not write the server response to the cache before onSettled triggers the invalidation. The stale invalidated data is served briefly. |
In onSuccess, call setQueryData with the server response before calling invalidateQueries in onSettled. This ensures the cache holds the correct value before the background refetch begins. |
| Apollo optimistic update applies correctly but is not reverted on error | cache.modify was used directly instead of inside an update function paired with optimisticResponse. cache.modify commits to the base cache and bypasses the optimistic layer. |
Migrate from standalone cache.modify to optimisticResponse + update. If you must use cache.modify, manually capture the field value before the mutation and restore it in onError. |
| Rapid sequential mutations on the same entity produce an out-of-order final cache state | Server responses arrive out of order (the second response arrives before the first), and each onSuccess overwrites setQueryData with whatever arrived last. |
Include a client-side sequence counter (mutationSeq) in the mutation context. In onSuccess, discard the response if its sequence number is not the latest. |
Frequently Asked Questions
Should I rollback the entire query cache or just the mutated entity on failure?
Roll back only the exact query key(s) the mutation touched. Full cache reversion causes unnecessary re-renders across every component with an active query, breaks independent query state for data that was not part of the mutation, and can overwrite successful mutations that completed concurrently. Scope getQueryData and the rollback setQueryData call to the precise keys involved — both the entity key and any list keys that include that entity.
How do I handle optimistic updates when the server returns a different entity shape than I predicted?
Add a response transformer in onSuccess that validates and maps the server payload to your normalized cache schema before committing. In TanStack Query, call setQueryData with the mapped value in onSuccess before invalidateQueries fires in onSettled. Use Zod or a similar runtime validator to detect shape mismatches early; a failed Zod parse should throw, causing onError to fire and roll back the optimistic state rather than silently writing a malformed entity to the cache.
Is it safe to combine optimistic updates with stale-while-revalidate?
Yes, with one guard: cancel outgoing queries in onMutate before writing the optimistic state. If a background stale-while-revalidate fetch is in flight and completes after your optimistic write, it will overwrite the optimistic payload with stale data, causing a visual revert that is not a real rollback. cancelQueries in TanStack Query or disabling revalidateOnFocus in SWR prevents this race. The onSettled invalidation then re-initiates a clean background fetch after the mutation settles.
Why does Apollo cache.modify not roll back automatically on mutation error?
cache.modify writes directly to the base cache, bypassing the optimistic layer stack. Apollo’s automatic rollback mechanism only operates on writes that went through the optimistic layer — which requires optimisticResponse to be set on the mutation. If you use cache.modify without optimisticResponse, Apollo has no record of the pre-mutation state and cannot revert it. The fix is to always pair optimisticResponse with the update callback and let Apollo manage the layer promotion and revert.
Related
- Cache Invalidation & Server Synchronization — the parent topic covering the full range of strategies for keeping client and server state aligned, including invalidation triggers and reconciliation patterns.
- Stale-While-Revalidate Implementation — how background refetch strategies interact with optimistic writes, and how to configure
staleTimeso SWR fetches do not overwrite in-flight mutations. - Background Refetch Strategies — deep dive into refetch triggers, deduplication, and how
invalidateQueriesinonSettledfits into the broader background-sync model. - Tag-Based Invalidation Systems — replace broad query-key invalidation with targeted tag-scoped cache eviction, reducing unnecessary refetches after mutation rollback.
- Entity Mapping Strategies — how stable entity IDs and normalized cache structures make per-entity rollback reliable and avoid the snapshot-scope bugs described in the pitfalls table above.