Optimistic UI Updates Rolling Back Unexpectedly on Request Failure
You click a button to like a post. The count jumps immediately β great UX. Then, half a second later, it snaps back to the original number. The request succeeded, but your UI rolled back anyway. This is one of the more frustrating bugs to track down because it only surfaces under timing conditions that are hard to reproduce consistently.
Optimistic updates are the right pattern for perceived performance. The problem is almost never the optimism itself β it's the rollback logic that fires when it shouldn't, or fails to fire correctly when it should.
What You'll Learn
- Why optimistic updates roll back even when the request succeeds
- The three most common root causes: aggressive rollback triggers, race conditions, and stale closures
- How to implement solid rollback logic with
useReducer - How TanStack Query handles optimistic updates and where it still requires care
- How to reconcile client state with the server's actual response
Prerequisites
This article assumes you're working in React (v18 or later) and have a basic grasp of hooks (useState, useReducer, useEffect). Code examples use plain React and TanStack Query v5. The concepts apply equally to Vue or Svelte with minor translation.
How Optimistic Updates Work (and Where They Break)
The optimistic update pattern follows three steps: apply the change to local state immediately, fire the async request in the background, then either confirm (do nothing, the UI is already right) or revert (restore the previous state) depending on the outcome.
In pseudocode that looks simple:
const previousValue = state.count;
setState({ count: state.count + 1 }); // optimistic
try {
await api.incrementCount(id);
} catch {
setState({ count: previousValue }); // rollback
}
This works fine in a toy example. Real apps break it in a handful of predictable ways that each require a slightly different fix.
The Three Root Causes of Unexpected Rollbacks
Cause 1: Rollback Logic That Fires Too Aggressively
The most common mistake is attaching rollback to any non-2xx HTTP status, including statuses that indicate the operation actually completed. A 304 Not Modified is not an error. Some APIs return 200 with an error payload inside the JSON body. If your rollback triggers on response.ok === false without reading the body, or if a middleware layer throws on certain success codes, you'll roll back a successful mutation.
Another version of this: an unhandled promise rejection elsewhere in the component causes a re-render that overwrites your optimistic state with the last-fetched server value. The request itself succeeded; the rollback happened because a different async operation failed.
Cause 2: Race Conditions Between Concurrent Mutations
A user clicks fast. Two mutations fire. The first one resolves and sets state. The second one resolves slightly later and sets state again β using a snapshot of previous state it captured when it was created, not the current state after mutation one applied. You get a stale write that looks like a rollback.
This is especially common with like/unlike toggles, cart quantity adjusters, and any interaction that users can trigger faster than a network round-trip. If you're also dealing with issues around stale state from closures, the problem in React's effect lifecycle can compound this β effects that fire twice in dev mode will trigger your mutation logic twice.
Cause 3: Stale Closure State Used During Rollback
When you capture previousValue inside a useState setter callback, that value is frozen at the time the closure was created. If another state update happens between the optimistic write and the rollback, the stale previousValue doesn't reflect the intermediate update. Rolling back to it discards the user's other changes.
// Dangerous: previousItems is a stale closure capture
const previousItems = items;
setItems([...items, newItem]); // optimistic
try {
await api.addItem(newItem);
} catch {
setItems(previousItems); // may overwrite other concurrent changes
}
Implementing Reliable Rollback With useReducer
useReducer gives you a single source of truth for state transitions, which makes rollback logic dramatically easier to reason about. Rather than juggling multiple useState variables, you keep a snapshot inside the reducer state that you can restore atomically.
const initialState = {
items: [],
snapshot: null, // saved before each optimistic mutation
status: 'idle',
};
function reducer(state, action) {
switch (action.type) {
case 'OPTIMISTIC_ADD':
return {
...state,
snapshot: state.items,
items: [...state.items, action.payload],
status: 'pending',
};
case 'COMMIT':
return { ...state, snapshot: null, status: 'idle' };
case 'ROLLBACK':
return {
...state,
items: state.snapshot ?? state.items,
snapshot: null,
status: 'error',
};
default:
return state;
}
}
Using this in a component:
function ItemList() {
const [state, dispatch] = useReducer(reducer, initialState);
async function handleAdd(newItem) {
dispatch({ type: 'OPTIMISTIC_ADD', payload: newItem });
try {
await api.addItem(newItem);
dispatch({ type: 'COMMIT' });
} catch (err) {
dispatch({ type: 'ROLLBACK' });
// show a toast or inline error here
}
}
return (
<ul>
{state.items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
Because the snapshot and the items array live in the same reducer state object, updates to one are always atomic with respect to the other. No stale closure problem.
Optimistic Updates With TanStack Query (React Query)
If you're using TanStack Query v5, the library provides a first-class onMutate / onError / onSettled lifecycle that's purpose-built for this pattern. The key is canceling in-flight refetches before you apply the optimistic update, otherwise a background refetch completing after your optimistic write will immediately overwrite it with old server data.
const mutation = useMutation({
mutationFn: (newItem) => api.addItem(newItem),
onMutate: async (newItem) => {
// Cancel any outgoing refetches so they don't clobber the optimistic update
await queryClient.cancelQueries({ queryKey: ['items'] });
// Snapshot the previous value
const previousItems = queryClient.getQueryData(['items']);
// Optimistically update the cache
queryClient.setQueryData(['items'], (old) => [...old, newItem]);
// Return context with the snapshot for rollback
return { previousItems };
},
onError: (err, newItem, context) => {
// Restore the previous cache value
queryClient.setQueryData(['items'], context.previousItems);
},
onSettled: () => {
// Always refetch after error or success to sync with the server
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});
The cancelQueries call in onMutate is the step most developers skip. Without it, an in-flight background refetch that was already running will resolve after your optimistic write and replace the cache with stale server data. This is the source of the phantom rollback that disappears when you disable background refetching in dev tools.
Handling Partial Failures and Server-Returned State
Not all failures are clean. Some APIs accept the request but return a modified version of the resource β the server might assign a different ID, apply a quota cap, or coerce a value. If you just confirm the optimistic update on success without replacing it with the server's response, your UI drifts from reality.
The right approach is to always apply the server's response on success, not just confirm the client-side guess:
onSuccess: (serverItem) => {
queryClient.setQueryData(['items'], (old) =>
old.map(item =>
item.id === serverItem.id ? serverItem : item
)
);
},
If the server returns a 200 but the response body contains an error flag (a pattern some legacy APIs use), you need to detect that explicitly and call your rollback path:
async function addItem(newItem) {
const res = await fetch('/api/items', {
method: 'POST',
body: JSON.stringify(newItem),
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error ?? 'Request failed');
}
return data;
}
Wrapping your fetch calls this way ensures your mutation library's onError path fires correctly instead of treating every 200 as a success. If you've ever seen fetch hang without a clear rejection, the pattern in handling indefinitely hanging fetch requests is worth reviewing alongside this.
Common Pitfalls
- Not deduplicating mutations. If the user triggers the same mutation twice quickly, you may have two in-flight requests and two snapshots. Only the last rollback will execute, and it may restore an out-of-date snapshot. Use a loading/pending flag to disable the trigger while a mutation is in flight.
- Rolling back on network timeout but not server error. Treat both the same. A
408or a rejected promise from a timeout both mean the mutation's outcome is unknown β roll back and tell the user. - Forgetting to show feedback after rollback. Silently snapping back to the previous state is confusing. Always pair a rollback with a visible error message or toast notification so the user knows their action didn't go through.
- Using component-local state for shared resources. If two components can both mutate the same resource (e.g., a shared cart), each one keeping its own snapshot will produce conflicts. Lift optimistic state to a shared context or use a server state library like TanStack Query with a shared query key.
- Retrying on rollback without user intent. Automatic retries after a failed mutation sound helpful, but they re-apply a change the user may have already corrected. If you retry, re-snapshot first.
Race conditions in async state can also surface in subtler ways. If your component is passing object or array literals as props that trigger unnecessary re-renders, the infinite re-render pattern with object literal props can mask where your state is actually being reset from.
Wrapping Up: Next Steps
Optimistic updates are a sound pattern, but they require more rigor than the basic try/catch example implies. Here's what to do right now:
- Audit your rollback triggers. Check that you're only rolling back on genuine failures β verify you're not treating 304s, rate-limit retries, or body-level error flags as network errors.
- Switch to
useReducerfor any component with both optimistic and concurrent mutations. Snapshot state atomically, not in a separate variable. - Add
cancelQueriesbefore every optimistic cache write in TanStack Query. This single step eliminates the most common phantom rollback. - Apply the server's response on success, not just a confirmation. Replace the optimistic item with what the server actually persisted.
- Always show user feedback on rollback. A silent snap-back erodes trust more than the error itself.
Frequently Asked Questions
Why does my optimistic update roll back even when the API request succeeds?
This usually happens because a background refetch completes after your optimistic write and overwrites the cache with older server data, or because your rollback logic is triggering on a non-error HTTP status. Canceling in-flight queries before applying the optimistic update (cancelQueries in TanStack Query) and auditing your error detection logic will fix both cases.
How do I prevent race conditions when a user triggers the same mutation multiple times quickly?
Disable the mutation trigger while a request is in flight using a pending/loading flag, so only one mutation runs at a time. If you must allow concurrent mutations, use a queue and ensure each rollback restores a snapshot taken immediately before its own optimistic write, not a shared snapshot.
Should I roll back optimistic state on a network timeout or only on server errors?
Roll back on both. A timeout means the outcome is unknown β the server may or may not have processed the request. Leaving the optimistic state applied gives the user false confidence that their action succeeded, which is worse than showing an error.
What is the difference between confirming and reconciling an optimistic update on success?
Confirming means you leave the optimistic state as-is because you assume the server accepted it exactly. Reconciling means you replace the optimistic state with the actual data the server returned. Reconciling is safer because the server may modify the resource (assign an ID, apply constraints) before responding.
Can I use the React useOptimistic hook instead of managing rollback manually?
Yes, useOptimistic (stable in React 19) handles the optimistic-to-committed state transition automatically when the async action resolves. It still requires proper error handling in the server action or async function β if that throws, React reverts to the previous state, so your error feedback logic still needs to be explicit.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!