Optimistic updates make an app feel instant: you click "like," the heart fills in immediately, and the network request happens quietly in the background. The failure mode nobody warns you about is what happens when two of those requests race, or when the optimistic update needs to roll back after the server disagrees.
The Naive Version
The first pass usually looks something like this, and it works right up until it doesn't:
async function toggleLike(postId: string) {
setLiked(true); // optimistic
try {
await api.likePost(postId);
} catch {
setLiked(false); // rollback
}
}
This is fine for a single, isolated toggle. It falls apart the moment a user double-clicks, or navigates away and back while the request is in flight, or the request that started first resolves after a second request. Now you can end up rolling back a state that a later, successful request already confirmed.
Tracking Requests, Not Just State
The fix that actually held up was to stop thinking about "the current liked state" and start thinking about "the most recent request I care about." I keep a ref with a monotonically increasing token and only apply results from the request that matches the latest token:
function useOptimisticToggle(initial: boolean, mutate: (next: boolean) => Promise<void>) {
const [value, setValue] = useState(initial);
const tokenRef = useRef(0);
const toggle = useCallback(async () => {
const next = !value;
const myToken = ++tokenRef.current;
setValue(next); // optimistic, immediately
try {
await mutate(next);
} catch {
if (tokenRef.current === myToken) setValue(!next); // only roll back if still current
}
}, [value, mutate]);
return [value, toggle] as const;
}
The key line is the tokenRef.current === myToken check before rolling back. If a newer toggle has already started, an older failing request has no business undoing it — it's stale information by the time it resolves.
Server Reconciliation
Optimistic state is a prediction, not a source of truth. When the real response comes back, I reconcile with the server's actual value rather than trusting my own guess, in case something else changed the like count in the meantime:
try {
const result = await mutate(next);
if (tokenRef.current === myToken) setValue(result.liked);
} catch {
if (tokenRef.current === myToken) setValue(!next);
}
That one extra step avoids a subtle class of bugs where the UI quietly drifts out of sync with the server after enough optimistic toggles.
When Not to Bother
Not every mutation needs this. For anything where the user waits for a full page transition anyway, or where a wrong optimistic guess would be confusing or costly to reverse (payments, deleting something irreversible), a normal loading state is simpler and safer. I reserve the optimistic pattern for small, frequent, easily-reversible interactions — likes, toggles, drag reordering — where instant feedback matters more than perfect certainty, and where a rare rollback is a minor visual blip rather than a real problem.
Wrapping It Once Instead of Reimplementing It
Once the token-based rollback logic proved itself on the like button, I generalized useOptimisticToggle into a slightly more flexible useOptimisticValue that takes an arbitrary next-value instead of just a boolean flip, and reused it for a handful of similar interactions — star ratings, follow/unfollow, reaction pickers. Writing the race-condition handling once, carefully, and reusing it everywhere turned out to be far more reliable than trusting myself to reimplement the same token-comparison logic correctly in five different components under deadline pressure.