Blog

The button that lies to you (nicely)

#engineering#react

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.

Nick@nicolabottarinow

grew a flower today

behind the glass

server
900ms

tap the heart and watch the wire

3 likes

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, latency })
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

Drag the latency slider down to zero. Now the whole thing looks pointless: why fake a number the truth would hand you instantly?

Now drag it up to a second and a half. The number still jumps the instant you tap, and the server catches up later, in its own time. That gap is the entire reason optimistic updates exist. A real like button is talking to a machine you don't own, somewhere across the network, having its own kind of day.

The like here is a stand-in for that machine: a server action that just waits the amount you dialled, then flips a coin. In a real app it's a request to a backend, and the fact that you can't make that backend fast, or make it say yes, is exactly why you move the number first and settle up after.

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.