The button that lies to you (nicely)
You tapped the heart and the number moved. Here's the uncomfortable part: the server hadn't agreed yet. It might never agree. The interface just decided to believe you.
That's an optimistic update. The polite lie an app tells so it doesn't feel slow, told on the bet that it can take the lie back before you notice, on the rare day the server says no.
Here's one you can poke. Tap the heart a few times. Then open the little panel underneath, set the server to fail on purpose, and watch what the number does.
grew a flower today
behind the glass
tap the heart and watch the wire
The lie
Nothing clever happens when you tap. The count goes up right away, in the browser, before a single byte leaves for anywhere.
// the number moves before a single byte leaves the browser
setLikes((n) => n + 1)That's the whole lie. We haven't asked the server anything. We moved the number on faith, because waiting for a round trip to show you your own tap feels broken, and this doesn't.
The reckoning
Then we ask, and we live with the answer. Not on every tap, though. Tap five times fast and firing five requests at a server that only needs to hear "plus five" is just noise. So we let your run of taps settle for a beat, then send one request for the whole lot.
// one request for the whole burst, then live with the answer
const { ok } = await like({ mood })
if (!ok) setLikes((n) => n - burst)The count still climbs on every tap, right there in the browser. It's only the asking that waits. If the server says yes, there's nothing left to do: the optimistic number was already right. If it says no, we take the whole burst back at once, and the count drops by however much that run had added.
That n - burst is the honest bit. It subtracts from wherever the count has got to by the time the answer lands, not from a snapshot it took on the way out, so a second burst you start while the first is still in flight can't trample it.
Why lie at all
Tap a few times and watch the wire. The number moves on every tap, but the request waits until your burst has been quiet for a beat. We could hold the number back too, and show all those likes only when the request leaves. The button would feel like it missed you, then suddenly caught up.
That pause is the debounce, not fake network latency. It is there to collapse a noisy run of taps into one useful request. A real backend adds a second, less predictable wait after that request leaves, but the demo does not need to manufacture one: the delayed send and the possibility of refusal already expose the machinery we care about.
The like here is a stand-in for that backend. It accepts or refuses the burst; the interface moves first and settles up when the answer arrives.
When the lie bites
Optimistic updates are a small trick with a few sharp edges.
- Only lie about what you can take back. A like, a bookmark, a row you dragged: cheap to reverse. A payment, a booking, a delete: not lies you want to tell. If undoing it on screen can't really undo it on the server, don't fake it.
- A counter is the forgiving case. Plus one, minus one, and it lands right even when taps overlap, because likes commute. Most optimistic state isn't so kind: when you replace a value or flip a toggle, remember what was there before you touched it and put that exact thing back, instead of trying to reverse-engineer it.
- Let the server have the last word. The optimistic number is a guess. Sooner or later you reconcile with the count the server actually reports, or two tabs drift and one of them is quietly wrong.
The grown-up versions
None of this needs a library. It's three moves: change the state, ask the server, undo on refusal. Libraries just give the moves names. React Query wraps them in onMutate and onError with a rollback context it hands you. React 19 ships a useOptimistic hook that keeps the books for you, though it also tucks away the very machinery this whole post is about. Both are worth reaching for, once you've seen what they're doing underneath. Which, now, you have.
Tap it a few more times before you go. The server doesn't mind. Nothing here was ever really keeping score.