Our services

If you can think it, we can make it brainsoft.

Optimistic UI updates that do not lie to the user

Written By: BrainSoft In Frontend

Optimistic updates feel great right up to the moment they lie. You tap a like button, it fills in instantly, the network drops, and ten seconds later it quietly flips back. The user already told three people they liked the post. That gap between what the screen showed and what the server stored is the whole problem, and it is a design problem more than a rendering one.

The short answer: apply the change locally, mark it as pending rather than confirmed, keep an undo path for every optimistic action, and only show a success state once the server acknowledges it. Below is how I build that in practice, including the cases where I refuse to be optimistic at all.

Separate the optimistic value from the confirmed value

The mistake I see most often is writing the optimistic result straight into the same field the server response populates. Once that happens you cannot tell whether a value is real or a guess, and rollback becomes guesswork too. Keep two things: the last value the server confirmed, and a list of in-flight mutations layered on top.

type Todo = { id: string; title: string; done: boolean };

type State = {
  confirmed: Todo[];
  pending: { id: string; patch: Partial<Todo> }[];
};

// what the user sees
const view = state.pending.reduce(
  (list, p) => list.map(t => (t.id === p.id ? { ...t, ...p.patch } : t)),
  state.confirmed
);

The rendered list is derived, never stored. When the request resolves, you drop the pending entry and replace the confirmed row with the server's version. If the request fails, you drop the pending entry and the row snaps back on its own, because the confirmed data was never touched. No manual undo bookkeeping, no stale snapshots.

Show pending, not done

An optimistic update should look optimistic. If a checkbox renders identically before and after the server confirms, the user has no way to know the write is still in flight, and no reason to trust the UI when it reverts. I keep the visual difference small but real: slightly muted text, a thin progress affordance, a disabled control. Not a spinner over the whole row, that is noise.

  • Pending rows get reduced opacity and a small inline indicator.
  • The control that triggered the write is disabled until it settles, so double taps cannot queue two mutations.
  • Confirmed rows lose the indicator and nothing else moves, so the layout does not jump.

This is where honesty lives. The user is not being told the save succeeded; they are being told it is being saved. That is a claim you can keep.

Make failure visible and recoverable

A silent revert is the worst outcome. The user thinks the app is glitchy or, worse, that it worked. When a mutation fails, I do three things: revert the derived view, show a short message next to the affected item, and offer a retry. If the action was destructive or expensive to redo, I keep the intent around so the retry is one click rather than a re-entry.

try {
  const saved = await api.updateTodo(id, patch);
  dispatch({ type: 'confirm', id, row: saved });
} catch (err) {
  dispatch({ type: 'revert', id });
  toast(`Could not save "${title}". Retry?`, { onRetry: () => save(id, patch) });
}

Note that the error message names the item. Generic toasts like "Something went wrong" make the user hunt for what changed. Naming the row turns a mystery into a decision.

Know when to stop being optimistic

Optimism is a bet that the server will agree. Some bets are bad. I wait for the server when the outcome depends on data the client does not own, when the action is hard to reverse, or when a wrong guess costs the user money or trust.

  • Payments, transfers, and anything with a balance: wait. Show a pending state and a clear result.
  • Uniqueness checks (usernames, slugs, booking slots): wait, or you will confirm an invalid state.
  • Server-computed values (totals, discounts, permissions): wait, since the client cannot predict them.
  • Low-stakes toggles, local reordering, draft text: be optimistic. The cost of a revert is a shrug.

For the middle ground, I sometimes do a hybrid: render optimistically but block the final action button until the server confirms the prerequisite. The user sees progress while the risky step stays honest. If you are wiring this into a larger front end with a real backend behind it, this is the kind of thing we handle in our services work.

Frequently asked questions

Should optimistic updates always roll back on error?

Almost always, yes. The exception is when the client can retry automatically and the action is idempotent, in which case you can keep the optimistic state and retry in the background. If retries are exhausted, revert and tell the user.

How do I handle two optimistic updates to the same row?

Queue them in order and apply them as a stack of patches over the confirmed value. If the server rejects one, drop the rest of the queue for that row and surface the failure, because applying later patches on top of a rejected base gives a state that never existed.

Does optimistic UI hurt accessibility?

It can if the only signal is a colour change. Pair the pending indicator with text or an aria-live region so screen readers announce the save and any failure. The revert should also be announced, not just animated.


#Frontend