The Lab · Development
Optimistic UI Updates That Never Lie to the User
Optimistic UI shows the result in under 16ms, before the server replies
Each line jumps to its section
- Optimistic UI shows the result in under 16ms, before the server replies
- Keep the old value stashed so rollback is a one-line restore
- Show a specific error on failure, not a silent revert
- Retry with backoff beats a dead button after one failed request
A like button that waits 400ms to turn red feels broken. A like button that turns red instantly and then quietly turns gray again when the request fails is worse. I'll show you the pattern I use to get the speed without the lie.
Optimistic UI means you paint the success state before the server confirms it. The trick is handling the moment it does not confirm. That's the whole post.
Why the delay feels so bad
A network round trip from Berlin to a server in the same region is maybe 30ms to 80ms. Add the server work, the database read, the serialization, and you're often at 200ms to 500ms for a trivial write. That's under the threshold where people call something slow, but it's well over the 100ms where an interaction stops feeling instant.
So the user taps. Nothing happens for a third of a second. Then the heart fills in.
That gap trains people to tap twice. They think the first tap missed. Now you've got two requests, a race condition, and a count that reads 2 when it should read 1.
Optimistic UI closes the gap. You update the local state the instant the tap lands, which runs in the next frame (roughly 16ms at 60fps). The server call fires in the background. If it succeeds, nothing visible changes because you already showed the right thing. If it fails, you clean up.
The honest version of this has three parts and most people only build one. They build the optimistic update and skip the rollback and the error message. That's the version that lies.
Here's the shape I keep coming back to:
async function toggleLike(postId) {
const previous = state.liked; // stash
state.liked = !state.liked; // paint now
render();
try {
await api.setLike(postId, state.liked);
} catch (err) {
state.liked = previous; // roll back
render();
showError("Could not save your like. Tap to retry.");
}
}
The stash line is the part that makes rollback cheap. You keep the old value in a const before you overwrite the state. If the request throws, you put it back. No refetch, no reload, no guessing what the value used to be.
I think about this the same way I think about database rollbacks. If you want the deeper context on undoing a change cleanly, see Database Migrations I Can Roll Back at 2am. Same instinct, different layer.
The rollback is the actual feature
Everyone shows the optimistic part in demos. The rollback is where honesty lives, and it's the part that gets cut when a deadline is near.
Think about what a user sees when the rollback is missing. They tap like and the heart fills, then they scroll away without noticing the request failed silently. They come back later and the heart is empty. Now they don't trust the button at all, because sometimes it works and sometimes it doesn't and there's no pattern they can see.
A good rollback does two things in the same tick. It restores the old value so the screen matches reality, and it tells the user why.
The restore has to be exact. If you had a count of 41 and you optimistically bumped it to 42, rolling back means going to 41, not decrementing from whatever the current value is. That matters because another update might have landed in between. The safe move is to stash the full value, not the delta.
const prevCount = state.count;
state.count = prevCount + 1;
render();
try {
await api.increment(postId);
} catch (err) {
state.count = prevCount; // exact restore, not count - 1
render();
}
For list operations the stash gets bigger. If you optimistically remove an item from a list and the delete fails, you need the item back and in the same position. I stash the item and its index before splicing, then splice it back on failure.
One thing I learned the hard way: don't animate the rollback the same way you animate the success. If deleting a row slides it out, don't slide it back in on failure. Snap it back instantly and flash the error. The slide-in reads as "here's a new thing," which confuses people. The snap reads as "that didn't happen," which is the truth.
When the error message is useless the whole pattern falls apart. "Something went wrong" tells the user nothing they can act on. "Could not save, check your connection" at least points somewhere. Structured logs on the server side tell you which failures are real. I set that up on day one, which I covered in Structured Logging I Add on Day One of Every Project.
Retry so one dropped packet doesn't kill the action
A single failed request is often not a real failure. It's a flaky connection, a cold server, a dropped packet on a subway WiFi. If you roll back and give up, you've punished the user for the network's problem.
So I retry before I roll back. Not forever, and not instantly.
The naive retry loops immediately and hammers a server that might already be struggling. If 500 clients all retry at the same millisecond, you get a thundering herd and the server that was slow becomes a server that's down. The fix is backoff with jitter: wait a bit longer each attempt, and add randomness so the retries spread out instead of landing in sync.
async function withRetry(fn, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
const base = 200 * 2 ** i; // 200, 400, 800
const jitter = Math.random() * 100; // spread them out
await new Promise(r => setTimeout(r, base + jitter));
}
}
}
Wrap the API call in that and the optimistic update stays painted the whole time. The user never sees the retries. They see the heart turn red, and it stays red because the second attempt landed. Only after three tries fail do you roll back and show the error.
I use this exact helper in a lot of places. The full version with the reasoning behind the numbers is in Retry With Backoff and Jitter: The Version I Copy Everywhere.
One caution: only retry things that are safe to repeat. A like toggle is idempotent if you send the target state (liked: true) instead of a command (toggle). Sending the target state means two retries that both land just set it to true twice, which is fine. Sending "toggle" twice flips it back, which is a bug. Design the API so retries can't hurt you, and the whole pattern gets safer.
And give people a manual retry too. After the automatic attempts fail, the error message itself should be the retry button. "Tap to retry" that re-runs the whole optimistic flow. That turns a dead end into a second chance without a page reload.
Where optimistic UI is a bad idea
Speed is not always worth the risk of showing something false. Some actions should wait for the server, and pretending otherwise erodes trust faster than any spinner.
Money is the obvious one. Never show "payment complete" optimistically. If the charge fails after you've shown success, you've told someone they paid when they didn't. Wait for the real confirmation. A 600ms spinner on a checkout button is fine because people expect money to take a moment.
Anything irreversible is the same category. Deleting an account, sending a message that can't be unsent, publishing something public. If rollback can't fully undo the visible effect, don't do the optimistic update.
The sweet spot is small, reversible, low-stakes actions. Likes, bookmarks, reordering a list, toggling a setting, adding to a cart. If it fails and rolls back, the user shrugs and taps again. Nobody's day is ruined.
There's also a counting problem to watch. If you optimistically update a number that other users also change, your local view can drift. Say a post has 100 likes, you add one and show 101, but three other people liked it in the same second so the real count is 104. Your optimistic 101 is now wrong in a way rollback won't catch, because the request succeeded.
I handle this by treating the server response as the source of truth when it arrives. The optimistic value is a guess. When the real count comes back, I replace the guess with it, even on success. The user sees 101 for a moment, then it snaps to 104, which reads as "oh, other people liked it too" rather than a bug.
For reads rather than writes, the performance questions are different. If your list is huge and the bottleneck is rendering, not the network, optimistic UI won't help. I measured that tradeoff in I Measured content-visibility: auto on 3,000 Cards, and the fix there is rendering strategy, not request timing.
Know which problem you have before you reach for this pattern. Optimistic UI fixes perceived latency on writes. It does nothing for a slow render.
Bottom Line
Build all three parts or skip the pattern. The optimistic paint is the easy 20 percent. The stash-and-restore rollback and the specific error message are the 80 percent that keeps the screen honest when the network drops a packet on a train.
My default: optimistic for small reversible writes, retry three times with backoff before giving up, and always let the server's real response overwrite my guess. For money and anything irreversible, I wait for confirmation and eat the spinner.
If you're wiring this into a project from scratch and want a setup that's structured for a solo build, Claude Blueprint is how I lay the groundwork before I write a single component.
Start with one button. Add the stash, the rollback, the error. Get that one honest, then copy the shape everywhere else.