Optimistic UI Without the Data Loss: A Practical Guide

Optimistic updates make apps feel instant — and, done carelessly, make them lie. Reversibility, race conditions, and how to reconcile with the server without losing user data.

Savenowrequestserverrollback if the server says no
Jim
Senior Frontend Engineer
Mar 4, 2026
6 min read

A support ticket came in that read: "I archived three emails, the app said they were archived, and this morning two of them are back in my inbox. Am I losing my mind?"

She was not losing her mind. Our archive action was optimistic — tap, the email slides away instantly, delightful — but two of those three requests had failed on a flaky train Wi-Fi connection, and our rollback was... let's say understated. The items quietly reappeared after a refetch, hours later, with no explanation. From the user's perspective, the app had lied to her, then gaslit her about it.

That's the deal with optimistic UI. It's one of the highest-leverage UX techniques we have — perceived latency drops to zero — and it's also a promise. The interface asserts "this happened" before it's true. Every piece of engineering around an optimistic update exists to make sure that promise is either kept or honestly, promptly walked back.

The three-part contract

Every optimistic update has the same anatomy, and skipping any part is where the bugs live:

  1. Apply — update the UI immediately, as if the server said yes.
  2. Confirm or roll back — when the server responds, reconcile reality with your prediction.
  3. Tell the user if you were wrong — a rollback nobody notices is data loss with extra steps.

Most PRs I review get part one right (it's the fun part), fumble part two, and forget part three entirely. Here's the shape of the fumble:

// Looks optimistic. Actually just... optimistic.
const handleArchive = (id: string) => {
  setEmails((prev) => prev.filter((e) => e.id !== id));
  api.archive(id); // fire and forget 🚨
};

No .catch. If the request fails, the UI and the server now permanently disagree, and the user finds out whenever the next refetch happens to run. If a diff shows a state update followed by an un-awaited mutation, that's not optimistic UI — that's wishful UI.

Rollback means keeping the receipt

To roll back, you need the previous state, captured before you touched anything:

const handleArchive = async (id: string) => {
  const snapshot = emails; // the receipt
  setEmails((prev) => prev.filter((e) => e.id !== id));
  try {
    await api.archive(id);
  } catch {
    setEmails(snapshot); // walk it back
    toast.error("Couldn't archive — it's back in your inbox.");
  }
};

This is the honest minimum, and notice the toast: the user saw the email leave. If it silently returns, you've traded a network error for a trust error. Say what happened, in human words, close in time to the action.

The snapshot-restore approach has a known weakness, though, which brings us to the part that separates a demo from production.

Where it gets real: concurrency

Snapshot rollback assumes nothing else changed between apply and failure. But users are fast. Archive email A, archive email B, A's request fails — and restoring A's snapshot resurrects B too, because B was still in that snapshot. Congratulations, your error handling just created a new wrong state.

A few battle-tested patterns, roughly in order of increasing robustness:

  • Targeted rollback. Don't restore a whole-list snapshot; reverse the specific change ("re-insert email A"). More bookkeeping, but rollbacks stop interfering with each other.
  • Disable per-item, not globally. While email A's archive is in flight, its own undo/related actions can be disabled — without locking the whole list. Never solve races by freezing the entire UI behind a spinner; that's just abandoning optimism with extra code.
  • Refetch as the tiebreaker. After any failure — or after all in-flight mutations settle — refetch the affected data and let the server's answer overwrite local guesses. Libraries like React Query formalize this: onMutate snapshots, onError restores, onSettled invalidates. If your app does optimistic updates in more than one place, you want this machinery, not seven bespoke try/catch dances.

And one more race that bites even careful code: out-of-order responses. The user toggles a setting on, then off. Request one is slow, request two is fast. Off lands first, then on arrives late and overwrites it. Now the UI shows the opposite of the user's last action, with no error anywhere. Fixes include ignoring responses from superseded requests (track a request id or use AbortController), or having the server return the authoritative state and always trusting the latest issued request only.

Choose where optimism is even appropriate

Not every action deserves optimism. My rule: be optimistic in proportion to reversibility and success rate.

  • Great candidates: likes, toggles, reordering, renaming, archiving — high success rate, cheap to reverse, low blood pressure if wrong.
  • Bad candidates: payments, sending email, deleting anything permanent, anything with side effects you can't claw back. "Your payment went through!" followed by "just kidding" is not a rollback, it's an incident.

For the destructive-but-common cases (delete, archive, remove member), the best pattern is often optimistic UI plus undo: apply instantly, show "Archived — Undo" for a few seconds, and only treat it as final after the window passes. You get the instant feel and a built-in recovery path that works for user regret and server failure alike.

Reviewing optimistic code (including the AI-generated kind)

Optimistic updates are a pattern AI assistants know well — ask for "optimistic like button" and you'll usually get the apply-snapshot-rollback skeleton, sometimes even the React Query version. What generated code reliably misses is the situational judgment: it doesn't know two mutations can be in flight at once on this screen, doesn't know this action is irreversible in your domain, and almost never adds the user-facing failure message. The skeleton looks textbook-correct, which makes it easy to wave through.

So regardless of who or what wrote it, I walk the same script:

  1. Force the failure. Block the request in dev tools. Does the UI recover to a truthful state? Does the user get told, in words, near the action?
  2. Double-fire it. Trigger the action twice fast (or two different items). Do the rollbacks interfere? Do late responses overwrite newer state?
  3. Check the eligibility call. Is this action reversible enough to deserve optimism at all? Would apply-plus-undo fit better?
  4. Find the reconciliation. After the dust settles, does anything re-sync with the server, or can drift live forever?
  5. Look for fire-and-forget. An optimistic state change with an unhandled promise next to it is the tell.

Keeping the promise

Optimistic UI is fundamentally a trust exercise. Users extend you credit — they believe the interface — and the engineering's job is to be good for it. Apply instantly, keep a receipt, expect overlapping actions, tell the truth fast when the server says no, and reserve your optimism for actions you can take back. Do that, and you get the magic of a zero-latency app without the morning-after support ticket asking whether the app, or the user, has lost its mind.

reactoptimistic-uiasyncrace-conditionsux
Written by
Jim
Senior Frontend Engineer · Firetrail review team
More Frontend