Tap the heart on Instagram. It fills in red before your finger leaves the glass. Swipe to archive in Gmail — gone, instantly. Neither app waited for a server to say "okay." They assumed success and moved on.
Now open the app you're building and click "Save." Watch the button freeze. One-Mississippi. The spinner. Then the UI updates.
That lag isn't the network being slow. It's a decision your code made: wait for the server before showing the user anything. You can make the other decision. The instant version is maybe four lines of code — but if you stop there, you've built a beautiful lie that desyncs the first time the network hiccups.
The fast part is easy. The part everyone gets wrong is what happens when the server says no.
What the slow version actually costs
Here's the pattern in nearly every codebase. Wait, then reflect:
// Pessimistic: nothing happens on screen until the server answers
async function handleLike(postId: string) {
setLoading(true);
await api.likePost(postId); // 300ms of dead air
setLiked(true);
setLoading(false);
}
The user taps. Nothing. Then the heart turns red. Technically correct, feels sluggish — and ~300ms is roughly the point where an interaction stops feeling like a direct response and starts feeling like a request you submitted. Worse, this fires on every single tap.
Act first, apologize later
Flip the order. Update the UI as if it already worked, then quietly reconcile:
// Optimistic: change now, roll back only if the server disagrees
async function handleLike(postId: string) {
setLiked(true); // instant
setCount(c => c + 1); // instant
try {
await api.likePost(postId);
} catch {
setLiked(false); // rollback
setCount(c => c - 1); // rollback
toast.error("Couldn't save — try again.");
}
}
The heart fills the instant you tap. On the 99% happy path, the await resolves and nothing visible happens — the optimistic state was right. On the rare failure, the heart goes grey and a toast explains why.
Spot what's carrying the whole pattern? It's not the two set calls at the top. Anyone writes those. It's the catch block — the part that knows exactly how to undo what it just did. That's the half people skip, and it's the half that matters.
Where manual rollback falls apart
A toggle is easy: one boolean to flip back. But the moment you're optimistically editing a list — adding, removing, reordering — manual rollback turns into a bookkeeping nightmare. What was the array before? What if a background refetch lands mid-mutation?
This is exactly the mess TanStack Query's mutation lifecycle is shaped to clean up:
const mutation = useMutation({
mutationFn: (newTodo: Todo) => api.createTodo(newTodo),
onMutate: async (newTodo) => {
// Stop in-flight refetches from clobbering our optimistic write
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData(["todos"]); // snapshot
queryClient.setQueryData(["todos"], (old: Todo[]) =>
[...old, newTodo]); // apply
return { previous }; // stash for rollback
},
onError: (_err, _newTodo, context) => {
queryClient.setQueryData(["todos"], context?.previous); // restore
toast.error("Failed to add todo.");
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }), // re-sync
});
Read it as four jobs, one each: snapshot → apply → restore-if-error → sync-always. onMutate saves and applies. onError puts the snapshot back. onSettled re-fetches no matter what, so the screen and the server agree at the end.
That cancelQueries line is the one everyone forgets, and it bites. Without it, a refetch that was already in flight can finish after your optimistic write and overwrite it with stale data — a flicker that's maddening to debug because it only shows up under the right timing.
The actions you should never fake
Optimistic UI is not free confidence. It fits low-stakes, high-frequency actions where failure is rare and undo is clean: likes, favorites, checking off a task, toggling a setting.
It's the wrong tool when:
- The action is irreversible. Placing an order, sending a message, deleting an account. Make the user wait and confirm — don't paper over real consequences with a fake "done."
- The server computes the result. A generated ID, a recalculated total, a merged state — you can't honestly predict it, so don't pretend to.
- Retries aren't safe. If a duplicate call could double-charge, slow down on purpose.
My one-line test: would the user be upset to learn it silently failed? Spinner. Would they just shrug and tap again? Go optimistic.
So why does the Save button still wait?
Because the happy path was the easy 90% and the team never built the other 10%. That's the whole story. Every implementation nails "update the UI fast." The rollback — snapshot, restore, tell the user — is what separates a polished app from one that quietly lies to people.
The takeaway for tomorrow's standup: optimistic updates aren't a library, they're a discipline — always handle the failure path explicitly. Snapshot before you touch state. Restore on error. Say something went wrong. Most users will never see those three steps. The ones who do will trust you more, not less.
What's the worst optimistic-update desync you've shipped — the ghost item, the double-count, the flicker that wouldn't reproduce? I'll go first in the comments.
Top comments (0)