Optimistic UI: Updating Before the Server Says Yes

Jul 17, 2026
11 min read
Optimistic UI: Updating Before the Server Says Yes

At the end of the last article I said that once you hold one coherent copy of server state and can invalidate it precisely, a door opens that used to be terrifying: updating the UI before the server confirms, and rolling back cleanly if it rejects. That's optimistic UI, and I want to be honest about what it actually is before we make it feel magic.

Optimistic UI is a lie. A small, well-intentioned, usually-correct lie — but a lie. You click the star, and the star fills in immediately, before the server has any idea you did anything. You're betting the request will succeed and showing the user the future you expect. Most of the time you're right and the app feels instant. The entire craft is in what happens the rare time you're wrong.

Why we bother lying at all

The alternative is honest and awful. User clicks, you show a spinner, you wait for the round trip, then you update. On a good connection that's 200-400ms of the UI sitting there doing nothing after a click that should have felt instant. On a train, it's two seconds of a spinning star. For a like button, a toggle, a reorder — high-frequency, low-stakes actions — that latency is the whole experience, and it makes a fast app feel slow.

So we front-run the server. The user's intent is almost always going to succeed, so we render success now and reconcile with reality when it arrives. The feeling is immediate; the correctness catches up a beat later.

Optimistic UI is a promise to the user: "I'll show you this now, and I guarantee I'll fix it if I was wrong." If you can't keep the second half, don't make the first.

The dangerous naive version

Here's the version I see most often, and it's the one that ships bugs:

// DON'T: optimism with no way back function useToggleStar(id: number) { const [starred, setStarred] = useState(false); const toggle = async () => { setStarred((s) => !s); // update immediately await api.setStar(id, !starred); // hope it works }; return { starred, toggle }; }

Read what happens when api.setStar throws. The state already flipped. There's no snapshot, no catch, no rollback. The star stays filled, the server never recorded it, and the user walks away believing they starred something they didn't. This isn't a cosmetic bug — the UI is now actively lying with no plan to stop. Optimism without rollback isn't optimistic UI. It's just a race you're pretending you won.

The real shape: snapshot, apply, reconcile

A correct optimistic update always has the same four beats. Cancel anything in flight that could clobber you. Snapshot the current truth. Apply the optimistic change. Then, when the request settles, either keep it (success) or restore the snapshot (failure). Because this series uses TanStack Query, the cache is where the truth lives, and the mutation lifecycle gives you exactly these hooks:

const toggleStar = useMutation({ mutationFn: (id: number) => api.toggleStar(id), onMutate: async (id) => { // 1. stop in-flight refetches so they can't overwrite our optimistic value await queryClient.cancelQueries({ queryKey: ['todo', id] }); // 2. snapshot the current truth so we can roll back const previous = queryClient.getQueryData<Todo>(['todo', id]); // 3. apply the optimistic change to the cache queryClient.setQueryData<Todo>(['todo', id], (old) => old ? { ...old, starred: !old.starred } : old, ); // hand the snapshot to onError via context return { previous }; }, onError: (_err, id, context) => { // 4a. reality rejected us — restore exactly what we had if (context?.previous) { queryClient.setQueryData(['todo', id], context.previous); } }, onSettled: (_data, _err, id) => { // 4b. reconcile with the server's version, success or failure queryClient.invalidateQueries({ queryKey: ['todo', id] }); }, });

Every line earns its place. cancelQueries is the one people skip and then can't explain the flicker: if a background refetch is already in flight, it will land after your optimistic write and stomp it with stale data. Snapshotting into context is what makes rollback surgical — you restore the exact previous object, not a guess. And onSettled invalidating is the humility step: whatever we optimistically showed, the server's answer is the real one, so we refetch and let truth win. This is why the previous article's precise invalidation mattered — optimistic UI is built directly on top of it.

Reconciliation is not "did it work" — it's "what's true now"

The subtle part is the reconcile step, because the world is not just success-or-failure. Sometimes the request succeeds but the server's result differs from what you optimistically drew. You optimistically added an item at the top of a list; the server inserts it sorted, so it's actually third. You optimistically set a counter to 5; someone else's change already made it 7.

The rule that keeps you sane: the server is the source of truth, and reconciliation means adopting its version, not defending yours. Your optimistic value was a placeholder to make the UI feel alive, nothing more. When the real data arrives, you replace the placeholder — even if it means the item you just "added to the top" visibly jumps to its sorted position. That tiny correction is honest and fine. Fighting to keep your optimistic guess because it looks smoother is how you end up with a client that slowly drifts out of sync with the backend.

Where it belongs, and where the lie gets expensive

I reach for optimistic UI when the action is cheap, reversible, and frequent, and when being wrong is cheap to correct. Toggling a star, liking a post, checking a checkbox, reordering a list, adding a comment. If the request fails, the rollback is a small, understandable flicker, and the user retries. The upside — instant feel — is huge and constant; the downside is rare and mild.

I do not reach for it when a wrong guess is expensive or confusing. Payments are the obvious one: never optimistically show "Payment successful." Showing success for a charge that then fails is worse than any spinner. Same for anything irreversible, anything with legal or money weight, and anything where the rollback would be more jarring than the wait — imagine optimistically navigating someone into a screen that then vanishes because the action failed. In those cases the honest spinner is the right call. The question isn't "can I make this optimistic?" It's "if I'm wrong, is the correction cheap and clear?" When the answer is no, don't tell the lie.

The reframe

What optimistic UI really taught me is that a good client models intent and confirmed truth as two different things, and knows how to move between them. The optimistic value is intent rendered early. The server response is confirmed truth. Reconciliation is the bridge. Once you hold those as separate ideas, the whole pattern stops feeling like a hack and starts feeling like what it is: a disciplined way to decouple how fast the UI feels from how fast the network actually is.

And notice what the rollback in onError really was — a single-step reversal back to a snapshot you took a moment ago. That works beautifully for one action. But the moment users want to reverse a sequence of actions — undo the last five edits in an editor, then redo two of them — a lone snapshot isn't enough. You need a history of reversible changes, which is a different and genuinely interesting problem. That's undo/redo, and it's next.

If you've got an optimistic update in your app right now, do me a favor and check one thing: what happens on onError? If you can't point to the line that restores the previous value, you don't have optimistic UI — you have a lie with no exit. Tell me what you find.

Found this helpful?

Subscribe on Telegram for new posts, or reach out to discuss your project.