cf.completefrontend
My progress
FUNDAMENTALS · MODULE 4

Optimistic updates

Showing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.

The problem: every click waits for a round trip

Picture a to-do app. You tick a box next to "Buy milk". The app sends PATCH /todos/42 with { "done": true }, waits for the server, and only then draws the tick.

On office Wi-Fi the answer comes back in 80 ms and nobody notices. On a train with a weak 4G signal it takes 900 ms, and sometimes 3 seconds.

For those 900 ms the box looks unticked. The user thinks the click missed and taps again. Now two requests are in flight: one sets done: true, the other may toggle it back. The app feels slow, and it also gets the wrong answer.

This is the pessimistic way to handle a change: assume nothing until the server confirms. It is always correct, but the user pays a full network round trip (the time for a request to reach the server and the answer to come back) for every small action.

Why 100 ms matters

Long-standing research on response times gives rough limits. Under about 100 ms, a result feels caused by your own action. Up to about 1 second, you notice the wait but keep your train of thought. Past that, the wait is clearly felt, people start to wonder whether the click worked, and by about 10 seconds their attention has gone elsewhere.

A round trip on a good connection is 30 to 100 ms; on mobile it is often 150 to 600 ms, plus the server's own work. So a pessimistic UI misses the 100 ms budget for most users, however fast the backend is.

Two timelines of ticking a to-do over 1,200 milliseconds on a mobile network, where the request takes 900 ms. Pessimistic: for 900 ms the box still looks unticked and the user may tap again, then it shows ticked. Optimistic: the box is ticked from the first frame, and the server confirms at 900 ms.Pessimistic: wait for the serverrequestserver: okscreenlooks unticked: tap again?tickedOptimistic: show it at oncerequestserver: okscreenticked from the first frame09001,200 ms
The request takes 900 ms either way. The pessimistic version spends it showing a screen that looks like the tap missed; the optimistic version spends it showing the result, and only changes again if the server says no.

The idea: assume success, keep a way back

An optimistic update flips the order. The moment the user acts, the app changes what it shows as if the server had already said yes. Then it sends the request. When the answer comes back, one of two things happens:

  • Success (the usual case, often over 99% of the time): the server's answer agrees with what the user already sees. The app may swap in the server's exact data, such as a real id or a timestamp, but nothing visible jumps.
  • Failure: the server rejects the change, or the network drops. The app puts the screen back the way it was (a rollback) and tells the user what happened, with a way to try again.

Where you already see it

You use optimistic updates many times a day. A like turns red at once. A chat message shows the moment you press Enter, with a small clock until the server accepts it. An archived email vanishes before the server has moved it.

What these share: the action is small, it almost always succeeds, and if it fails the app can undo it without harm. Those three conditions are the whole test for when to use the pattern, and chapter 8 comes back to them for the cases where they do not hold, such as payments.

Think first

In the pessimistic to-do app, the user taps the box twice because nothing happened. Why can that leave the to-do in the wrong state, even though both requests succeed?

The mental model: confirmed state plus pending changes

The easiest way to get optimistic updates right is to stop thinking of "the data" as one thing. Keep two things in your head instead: what the server has confirmed, and a short list of pending changes the user made that the server has not confirmed yet.

What the user sees is the confirmed state with the pending changes applied on top, in order. The confirmed state only changes when the server says something. The pending list grows when the user acts and shrinks when the server answers, whether the answer is yes or no.

ts
type Change = { id: string; apply: (todos: Todo[]) => Todo[] };

// What the server has confirmed. Only server answers write here.
let confirmed: Todo[] = [];
// What the user did that the server hasn't confirmed yet, oldest first.
let pending: Change[] = [];

// What the screen shows: always derived, never stored.
function visible(): Todo[] {
  return pending.reduce((todos, change) => change.apply(todos), confirmed);
}
The whole idea in eleven lines. Real libraries add caching and subscriptions, but this is the model they implement.
Three layers stacked. At the bottom, confirmed by the server: Buy milk, Pay rent, Book dentist. In the middle, pending changes, oldest first: add Call mum, then delete Buy milk. At the top, what the screen shows: Pay rent, Book dentist, and Call mum shown as pending. User actions go into the pending layer; server answers go into the confirmed layer; the screen is always the confirmed layer with the pending changes applied on top.What the screen showsPay rentBook dentistCall mumPending changes, oldest first1. add Call mum2. delete Buy milkConfirmed by the serverBuy milkPay rentBook dentistscreen =confirmed+ pendinguser actsserver answers
Only server answers write to the bottom layer, and only user actions add to the middle one. The top layer is never stored: it is recomputed whenever either layer below changes.

Pencil on top of ink

Think of a shared paper calendar. Entries confirmed by the office are written in ink. When you want a meeting, you pencil it in at once so you can plan around it, and you send a request to the office.

If the office confirms, the pencil is traced over in ink. If it says no, you rub the pencil out. You never rub out ink because of a pencil mark, and you never write in ink before the office answers.

That is the rule that keeps optimistic code sane: server data and guesses live apart. Mix them in one store and you can no longer tell which values are real, so rollback code has to guess what the value was before.

The life of one change

Each pending change moves through a few states. Naming them helps both the code and the UI, because each state looks a little different on screen.

  • Pending: the user acted and the request is in flight. The change is visible, sometimes with a faint style or a small clock icon.
  • Confirmed: the server said yes. The server's answer goes into confirmed state and the change leaves the pending list. Nothing visible moves.
  • Failed: the server said no, or the request timed out. The change leaves the pending list, so the screen goes back to confirmed state, and the user sees why. Some apps keep the item in red with Retry.
  • Retrying: the user (or the app, for network errors) sends it again. It goes back to pending.
A state machine for one optimistic change. A user action puts it in pending, shown at once, sometimes faded. From pending, the server saying yes moves it to confirmed, shown in the normal style. A rejection, or retries running out, moves it to failed, shown in red with Retry. From failed, retry goes back to pending, and discard removes it, which is the rollback. A network error loops from pending back to pending as an automatic retry with the same key.user actspendingtimeout: retry, same keyconfirmednormal stylefailedgoneserver: yesserver: nodiscardretryred, with Retryrolled back
Every state needs its own look on screen. Pending and failed are the ones teams forget, and they are where users stop trusting an app that pretends everything worked.

Check your understanding · Exercise 1

The confirmed list has 3 to-dos. The user adds "Call mum" (pending) and then deletes "Buy milk" (pending). The add fails. What should the screen show? Pick one.

How it works, step by step

Let's follow one like on a post, with real values. The post has likeCount: 41 and likedByMe: false. The user is on mobile, where this request takes about 400 ms.

  1. 0 ms: the tap. The click handler runs. Nothing has gone to the network yet.
  2. 0 ms: stop stale reads. Cancel or ignore any in-flight refetch of this post, or its older answer could overwrite the guess.
  3. 0 ms: remember the way back. Record a pending change, like:post-7.
  4. 0 ms: apply. The visible post becomes likeCount: 42, likedByMe: true. React renders it in the same frame, so the heart turns red within about 16 ms.
  5. 1 ms: send. PUT /posts/7/like with { "liked": true }. It sets a value instead of flipping one, so a retry can't count twice.
  6. 400 ms: the answer. The server returns 200 with { "likeCount": 57, "likedByMe": true }. Other people liked the post in the meantime, so the real count is 57, not 42.
  7. 400 ms: reconcile. Write the server's post into confirmed state and drop the pending change. The screen shows 57 and a red heart.
A sequence diagram with three columns: the user, the UI and its cache, and the server. The user taps like at 0 ms. The UI cancels the post's in-flight refetch and records a pending change, then shows the post liked with 42 likes within 16 ms. The UI sends PUT /posts/7/like. At 400 ms the server answers 200 with likeCount 57. The UI saves the server's answer as confirmed and drops the pending change, and the user now sees 57 likes.UserUI and cacheServertap at 0 mscancel the refetchrecord the changeliked, 42 (16 ms)PUT /posts/7/like200 at 400 ms: 57save server answerdrop pending changeliked, 57
The user sees the result before the request even leaves. The answer from the server, 57 rather than the guessed 42, replaces the guess the moment it arrives.

Step 7 is easy to skip and important to keep. The optimistic value was a guess built from old data. The server's answer is the truth, and it may differ for reasons the client could not know: other users, server rules, rounding, timestamps. Always let the server's answer win once it arrives.

The failure path

Now say the server answers 404 because the post was deleted a minute ago. The pending change is dropped, so the screen goes back to confirmed state: 41 likes and a grey heart. The app shows a short message, "Couldn't like this post. It may have been removed.", and may refetch the post to learn what really happened.

A network error is different: the server may have saved the like and only the answer got lost, so rolling back at once can be wrong. Retry with the same action key and roll back only if retries run out; chapter 6 and the lesson on retries and backoff cover this.

Try it: a to-do list with a server that can say no

The lab below runs both styles side by side on the same fake server. Set the delay and the chance of failure, add a few to-dos, and watch the difference. Notice that in the optimistic mode, a failure is the only time the user sees the network at all.

Show new items:
Server delay:
The screen shows 1 items: Buy milk (saved). Pending changes: none. The server has saved 1: #101 Buy milk.ScreenBuy milk#101PendingServer#101 Buy milk
Hatched rows are guesses whose save is still in the pending column, green rows are confirmed, red rows failed. In the wait mode, the middle column only lists requests in flight: nothing is on screen until the server answers. The server column is the truth the screen has to end up matching.

Add an item, then make one fail. Watch the three columns.

Think first

The optimistic like showed 42. The server says 57. Isn't it jarring to jump from 42 to 57? Should the client keep its own count instead?

Rolling back: three ways, one that scales

Rollback sounds simple: put things back. The hard part is deciding what "back" means when more than one change is in flight. There are three common ways to do it, and they behave the same with one change and very differently with two.

1. Snapshot and restore

Before applying the change, copy the current data. On failure, write the copy back. This is what most library examples show, because it is short and works for a single change.

It breaks when changes overlap. The user renames to-do A (snapshot S1 taken), then, before that answer comes back, ticks to-do B (snapshot S2 taken, which already includes the rename).

If the rename fails, restoring S1 also wipes out the tick of B, which is still pending and may succeed. If the tick fails later, restoring S2 brings back the failed rename. Each snapshot holds other changes frozen in time.

Two to-dos, A titled Milk and B open, under two rollback strategies. Step 1: the user renames A to Milk 2L, pending. Step 2: the user ticks B, pending. Step 3: the rename fails. With snapshot rollback, the snapshot from before step 1 is restored, so A is Milk and B is open again, even though B's tick is still pending. With rebase, only the rename is removed, so A is Milk and B stays done. Step 4: the tick is saved. With snapshot rollback, B jumps back to done after a refetch. With rebase, nothing moves.Snapshot rollbackRebaseABAB1. rename AMilk 2LopenMilk 2Lopen2. tick BMilk 2LdoneMilk 2Ldone3. rename failsMilkopenMilkdone4. tick savedMilkdoneMilkdoneB flips back, then forwardB never moves
Hatched cells are pending guesses. The snapshot taken at step 1 knew nothing about step 2, so restoring it wiped out a change that was about to succeed. Rebase removes only the change that failed.

2. Apply the inverse

For each change, store how to undo just that change: "set A's title back to Milk", "set B's done back to false". On failure, apply only that inverse. This leaves other changes alone, which fixes the overlap problem in simple cases.

Its weakness: the inverse was computed from the value at the time. If the server pushed a newer title for A meanwhile, "set back to Milk" restores a stale value. Inverses also get hard for moves and reorders.

3. Rebase: recompute from confirmed state

Keep the two layers from chapter 2. On failure, remove the failed change from the pending list and recompute the screen: confirmed state plus the remaining pending changes. On success, write the server's answer into confirmed state and remove the change. Nothing is ever "undone"; the screen is just recomputed.

Sync engines such as Replicache call this rebasing, borrowing the git word. It handles overlapping changes, server pushes, and refetches the same way. The cost: changes must be stored as replayable operations, not finished values.

Decision How should the client undo a failed optimistic change?

Snapshot and restore

Copy the cached data before the change

On error, write the copy back

Pros

  • Shortest code; built into most library examples
  • Always correct when only one change is in flight

Cons

  • Overlapping changes clobber each other
  • Wipes out server updates that arrived during the request

Inverse operation

Store an undo for each change, such as the old field value

On error, apply only that undo

Pros

  • Leaves other pending changes alone
  • Small memory cost

Cons

  • The undo can restore a value that is now stale
  • Hard to write for moves, reorders, and list inserts

Rebase on confirmed state

Keep confirmed data and a list of pending changes apart

On any answer, recompute: confirmed plus remaining pending

Pros

  • Correct with many overlapping changes and live server pushes
  • One rule for success, failure, and refetch

Cons

  • Changes must be replayable functions
  • More code, or a library that does it for you

What we pick: Snapshots are fine for one-at-a-time actions such as a like or a single form, especially if you serialise changes to the same item. Once a screen allows many overlapping edits (a board, a spreadsheet, a chat), rebase. Treat inverse operations as a stepping stone, not a goal.

In code: React Query's onMutate, onError, onSettled

TanStack Query (React Query) gives you hooks at each step of a mutation. The snapshot pattern looks like this. onMutate runs before the request; whatever it returns is passed to onError and onSettled.

ts
const queryClient = useQueryClient();
const toggle = useMutation({
  mutationFn: (todo: Todo) => api.patchTodo(todo.id, { done: !todo.done }),
  onMutate: async (todo) => {
    // 1. Stop in-flight refetches from overwriting our guess.
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    // 2. Snapshot, then 3. apply the guess.
    const previous = queryClient.getQueryData<Todo[]>(["todos"]);
    queryClient.setQueryData<Todo[]>(["todos"], (old = []) =>
      old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
    );
    return { previous };
  },
  onError: (_error, _todo, ctx) => {
    // 4. Roll back to the snapshot.
    if (ctx?.previous) queryClient.setQueryData(["todos"], ctx.previous);
  },
  onSettled: () => {
    // 5. Either way, refetch so the cache ends up matching the server.
    return queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});
TanStack Query v5. Note the snapshot: safe for one toggle at a time, but two quick toggles on different to-dos can clobber each other as described above.

cancelQueries stops a stale refetch from landing on top of the guess, and onSettled refetches either way, so any drift between guess and truth is fixed one round trip later. For overlapping edits, make onError undo only this to-do (an inverse).

React Query v5 also has a lighter style: leave the cache alone and render the pending item from the mutation's variables (the arguments passed to mutate) while isPending is true. On failure the item stops rendering, so there is no rollback code. It suits adding one item to one list; when other components must show the pending item too, they need useMutationState, and the cache style is often simpler.

In code: React 19's useOptimistic

React 19 added useOptimistic. It is not a data-fetching tool: it never talks to the server. It gives a component a temporary layer on top of a value it already has, which lasts while an Action (an async transition) is running.

The setter must be called inside that Action; a form's action prop already runs in one. When the Action ends, React drops the layer and shows the real value again.

tsx
function Thread({ messages, send }: { messages: Msg[]; send: (text: string) => Promise<void> }) {
  const [shown, addPending] = useOptimistic(
    messages,
    (current: Msg[], draft: Msg) => [...current, { ...draft, sending: true }],
  );
  const [error, setError] = useState("");

  async function action(form: FormData) {
    const text = String(form.get("text"));
    setError("");
    addPending({ id: crypto.randomUUID(), text }); // visible at once
    try {
      await send(text); // on success the parent adds it to `messages` in startTransition
    } catch {
      setError("Message not sent."); // the layer drops by itself
    }
  }

  return (
    <form action={action}>
      {shown.map((m) => <p key={m.id}>{m.text}{m.sending ? " (sending)" : ""}</p>)}
      <input name="text" aria-label="Message" />
      {error ? <p role="alert">{error}</p> : null}
    </form>
  );
}
Rollback is automatic: when the Action finishes, shown goes back to messages. If send succeeded, the parent's new messages includes the real message; if it failed, it doesn't.

This is the rebase model from chapter 2 built into React: messages is confirmed state, and the update function is a pending change replayed on top of whatever messages currently is. Overlapping sends work too. React may replay the update function, so keep it pure: create the id in the Action, as above.

What you don't get is a failed message kept in red with Retry: the optimistic copy is gone when the Action ends, so store failed messages in normal state yourself.

Check your understanding · Exercise 2

A board uses the React Query snapshot pattern above. The user drags card X to Done, then within 300 ms drags card Y to Done. The first request fails, the second succeeds. What can the user see? Select all that apply.

Temporary IDs and reconciling with the server

Changing an existing item is the easy case: it already has an id. Creating a new item is harder. The server usually picks the id, say todo_9f2c, and the client won't know it for another 400 ms. But the new to-do has to appear in the list now, and a list needs a key for each row.

The usual answer is a temporary id: the client makes one up, such as tmp_1, shows the item under it, and swaps in the real id when the server answers. That swap is called reconciling. It sounds trivial and causes a surprising number of bugs.

A new to-do, Call mum, appears at once under the temporary id tmp_1, marked as saving. A POST goes to the API, which creates the row and answers 201 with the real id todo_9f2c. The to-do then shows as saved under todo_9f2c. Below, an id map pairs tmp_1 with todo_9f2c, and the React key stays tmp_1 so the row is not rebuilt. A queue holds a tick the user made while saving, PATCH done, which waits for the real id and is then sent to todo_9f2c.on screen at onceCall mumtmp_1, savingPOSTAPIcreates the row201after the answerCall mumtodo_9f2c, savedid maptmp_1todo_9f2cReact key stays tmp_1so the row is not rebuiltwaiting for the real idPATCH donethe tick made while saving,sent to todo_9f2c on 201
The row is on screen before the server has heard of it. When 201 comes back, the map gets the real id, the key stays the same, and the queued tick is sent to todo_9f2c.

What breaks during the gap

  • Follow-up actions. The user adds "Call mum" and ticks it 200 ms later. PATCH /todos/tmp_1 would fail: the server has never heard of tmp_1. Queue the tick until the real id is known.
  • Remounting. If the row's React key is the id, swapping tmp_1 for todo_9f2c makes React rebuild the row: focus is lost and enter animations play twice.
  • Duplicates. A refetch lands after the create succeeded but before you swapped ids. Now the list has the confirmed todo_9f2c and the pending tmp_1: the same to-do twice.
  • Links and URLs. The user opens the new item and the URL becomes /todos/tmp_1. They copy the link, or reload, and get a 404.

The cleaner fix: let the client choose the id

Most of these bugs go away if the id the client makes up is the real id. The client generates a UUID with crypto.randomUUID() (in all modern browsers, on HTTPS pages and localhost) and sends it with the create. The server checks it is well formed and unused, and stores the item under it. The id never changes, so there is nothing to reconcile.

http
POST /todos
Content-Type: application/json

{ "id": "6f1c2b8e-4f0a-4c6e-9a77-2b1d5e0c9a13", "title": "Call mum" }

HTTP/1.1 201 Created
{ "id": "6f1c2b8e-4f0a-4c6e-9a77-2b1d5e0c9a13", "title": "Call mum",
  "done": false, "createdAt": "2026-03-02T09:14:07Z" }
The client picks the id; the server fills in what only it knows, such as createdAt. A retry of the same body is now naturally safe: the second insert hits the same id.

This also makes retries safe. If the network drops after the server saved the to-do, the client sends the same request again, and the server sees the id already exists and returns the stored item instead of making a second one. That is the same job an Idempotency-Key header does, which the lesson on retries and backoff covers.

When the server must own the id (an auto-increment database, an order number with a format, a legacy API), keep two fields apart: a stable clientId used as the React key and for local lookups, and a serverId that starts empty and is filled in on success. Requests that depend on the new item wait in a small queue until serverId exists.

If you control the API, client-generated ids remove a whole class of bugs.
QuestionTemporary id, swapped laterClient-generated UUIDclientId plus serverId
Row remounts on save?Yes, unless the key is separateNoNo, the key is clientId
Edits before the create returnsQueue and rewrite idsSend at onceQueue until serverId is set
Safe to retry the create?Only with an idempotency keyYes, same idOnly with an idempotency key
Server changes neededNoneAccept and validate client idsNone

Matching answers to pending changes

When the server's version of a list arrives, by refetch or WebSocket push, the client must know which pending changes it already includes, or it shows a change twice or drops one.

The reliable way is to give each change its own id (a mutation id) and have the server report which ones it has applied, either in the response or as a "last applied mutation id" per client. Chat apps do this: each outgoing message carries a client id, and when the server echoes the message back on the socket, the client matches it to the pending bubble and marks it sent, instead of showing it twice.

Check your understanding · Exercise 3

A chat app shows a new message under tmp_7 and uses the id as the React key. When the server answers, the bubble flickers and the screen reader announces the message twice. What fixes it? Select all that apply.

Ordering, races, and conflicts

An optimistic UI lets the user keep going, so many requests can be in flight at once, and requests that leave in one order can be processed in another. Here are the three races that matter for writes; the lesson on request races covers out-of-order reads in depth.

Race 1: a stale read lands on top of the guess

The list refetches every 30 seconds, or when the tab regains focus. A refetch leaves at 0 ms. The user ticks a to-do at 50 ms, and the screen shows it ticked.

At 120 ms the refetch answers with data the server read before the tick, so the to-do is unticked. If the client writes that into the cache, the tick vanishes. At 450 ms the tick's own answer arrives and it comes back. The user sees tick, untick, tick.

Two timelines over 600 milliseconds. A refetch of the list starts at 0 ms; the user ticks a to-do at 50 ms and its write is answered at 450 ms. Without cancelling: the refetch answers at 120 ms with old data, so the screen shows ticked from 50 to 120 ms, unticked from 120 to 450 ms, and ticked again after 450 ms. With the refetch cancelled at the tap: the screen shows ticked from 50 ms onwards and never flips.Refetch not cancelledrefetchold data at 120 mstick writescreenunticked againtickedRefetch cancelled at the taprefetchcancelled at the taptick writescreenticked from 50 ms, never flips050120450600 ms
The refetch left before the tick, so its data is older than the guess. Cancel it (cancelQueries) or keep guesses in their own layer, and the old data cannot overwrite what the user just did.

Two fixes: cancel or ignore reads of the same data while a write is pending (cancelQueries), or use the two-layer model, where the stale read only updates confirmed state and the pending tick is replayed on top.

Race 2: two writes to the same item

The user likes a post, then unlikes it 150 ms later. Two requests leave: POST /like and DELETE /like.

They may travel on different connections, or the same HTTP/2 connection, and the server may handle them on different machines. If the unlike is processed first, the server ends up with the post liked, while the screen shows it unliked. Nothing ever corrects it until a reload.

  • Send absolute values, not toggles. PUT /posts/7/like with { "liked": false } is safe to repeat: sending it twice leaves the same result. A toggle is not: if a timed-out toggle is retried and both copies arrive, the like flips twice. Absolute values still need the right order, which the next two fixes give.
  • One request in flight per item. Keep a small queue per item. While the like is in flight, the unlike waits. When the like returns, send the unlike. Requests to different items still run in parallel.
  • Collapse to the latest intent. While a request is in flight, newer changes to the same item replace each other in the queue, so a burst of taps during one request sends at most one more. The lesson on debounce and throttle shows a related trick.
  • Sequence numbers. Each write carries a growing number per item, and the server ignores writes older than one it has applied. Useful when the client can't serialise, for example across tabs.
Taps on the heart:
Writes:
2 taps at 0 ms (like), 150 ms (unlike). 2 requests: liked true sent at 0 ms, applied at 350 ms; liked false sent at 150 ms, applied at 250 ms. The screen shows unliked; the server ends liked.tapslikeunlikeliked: trueliked: false02505007501,000 ms
Each bar is one request, from send to answer; the dot is when the server applies it. The server keeps whichever write it applies last, which is not always the one the user made last.

Screen shows unliked. Server ends liked. They disagree, and nothing will fix it until a reload. 2 requests sent.

Race 3: someone else changed it

Two people edit the same task title. Both see "Draft". Alice saves "Draft v2" and Bob saves "Final" a second later. Without any check, the server keeps whichever arrived last (last write wins), and Alice's screen shows "Draft v2" until she refetches.

To catch this, send the version you edited. With HTTP, the server gives each version an ETag, and the client sends it back in If-Match. If the item changed since, the server answers 412 Precondition Failed (many APIs use 409 Conflict with a version field in the body instead). The client then has a real decision to make, which is product design, not plumbing:

  • Server wins: roll back, show the new value, and say "Bob changed this a moment ago". Good for small fields.
  • Merge per field: if Alice changed the title and Bob changed the due date, keep both. Only same-field clashes need a choice.
  • Ask the user: show both versions. Right for long text where losing either side hurts.
  • Merge automatically: for real-time co-editing of documents, use operational transforms or CRDTs, covered in the collaboration module.

When you don't know if it worked

A timeout is not an answer. The server may have saved the change, or never seen it. Rolling back may hide a real change; keeping it may show a fake one.

The safe pattern: keep it pending, retry with the same idempotency key a few times with backoff, then mark it failed. Offline, keep pending changes in a queue (in IndexedDB to survive a reload) and send them when the connection returns; the lessons on background sync and offline-first go further.

Check your understanding · Exercise 4

Users report that likes sometimes "come back" after they unlike a post quickly. Which changes fix it? Select all that apply.

Showing pending and failed changes

An optimistic update hides the network, but not completely. A pending change can be lost if the tab closes, and a failed one must be noticed. Show nothing for fast successes, a gentle hint for slow ones, and a clear message for failures.

Three levels of feedback

  • Instant, no hint: likes, ticks, reactions. The change is small, almost always works, and a pending icon would only add noise. If it fails, a short message is enough.
  • Instant with a pending hint: chat messages, comments, uploads. The item appears at once, faded or with a clock icon, and turns normal when confirmed.
  • Instant with a delayed hint: show a small "Saving..." only if it takes longer than about 500 ms. Fast networks stay clean and slow ones get reassurance.
A wireframe of a chat window with three of the user's messages. See you at 6 is confirmed, in the normal style, marked sent. Running late is pending, faded and marked sending. Order pizza? failed, shown in red with the text Not sent and a Retry button. At the bottom, a status line, read by screen readers, says Message not sent. Notes on the right explain each style.ChatSee you at 6sentRunning latesendingOrder pizza?Not sent. Retrystatus: Message not sent.confirmed:normal stylepending: faded,and says sofailed: text kept,Retry next to itlive region:said once
Each state looks different and says so in words, not only in colour. The failed message keeps the user's text where they typed it, and the status line tells screen reader users once.

Failures the user can act on

When a change fails, say what failed, not just that something did. "Couldn't send 'See you at 6'" beats "Something went wrong". Put the message next to the item when you can, and offer the obvious next step: Retry, Edit, or Discard.

A failed like can simply revert with a toast. A typed chat message or comment must never vanish: keep it in red with Retry, or you throw away the user's work.

Accessibility

A sighted user sees a heart turn back to grey. A screen reader user hears nothing unless you say it. Announce rollbacks and failures through a polite live region, an element with aria-live="polite" or role="status" that already exists on the page. Use role="alert" only for failures that need attention now, such as a message that was not sent.

  • Keep the control's state honest. A like button is a toggle with aria-pressed; flip it optimistically along with the icon, and flip it back on rollback.
  • Don't move focus on success or rollback. Rebuilding a row (the remount bug from chapter 5) drops focus to the page body, which is very disorienting with a keyboard or screen reader.
  • Don't announce every success; announce what did not happen as expected.
  • Mark pending items in text, not only by colour or opacity: "Sending" in visually hidden text, or a labelled icon.

Don't lose pending work

If the user closes the tab while changes are pending, they are lost, and the user believes they were saved. Two common guards: listen for beforeunload and ask the browser to show its "leave site?" prompt only while the pending list is not empty, and for important data, write pending changes to IndexedDB so they can be sent on the next visit.

Think first

A to-do app removes a deleted to-do at once. The delete fails 2 seconds later, when the user has scrolled away. How do you tell them?

When not to be optimistic: payments and other promises

An optimistic update says "this worked" a few hundred milliseconds before you know. That is fine when taking it back costs nothing, and not fine when the user acts on it or the server decides the result.

Three questions before you go optimistic

  1. Does it almost always succeed? If more than about 1 in 100 attempts fail in normal use, users will see rollbacks often, and a UI that keeps changing its mind feels worse than a spinner.
  2. Can the client predict the result? Ticking a box: yes, it becomes ticked. Booking a seat, applying a coupon, picking a username: no, only the server knows if the seat is free or the code is valid.
  3. Is a rollback harmless? If the user, or another person, or another system acts on the optimistic result before it is confirmed, a rollback can't undo that. A like is harmless. "Payment successful" is not.

If either of the first two answers is no, wait for the server. If only the third is no, a delayed commit with Undo (below) can work, unless money moves. Waiting doesn't have to feel slow: disable the button so it can't be pressed twice, show progress in the button itself, and keep the rest of the page usable.

A decision flowchart. First question: does the action almost always work? If no, wait for the server. If yes, second question: can the client know the result? If no, wait for the server, as for payments and bookings. If yes, third question: is a rollback harmless? If no, use a delayed commit with Undo, as for send and delete, or wait if money moves. If yes, be optimistic, as for likes, ticks, and chat.Almost always works?Client knows the result?Rollback is harmless?Optimisticlikes, ticks, chat, reordersyesyesyesWait for serverpayments, bookingsDelay + Undosend, delete, archivenononoor wait, if money moves
All three answers must be yes before you show a result the server hasn't confirmed. A single no sends the action to one of the safer patterns on the right.

Why payments are the classic "no"

Picture a checkout that shows "Order placed!" the moment the user taps Pay. The card is declined 2 seconds later, after the user has closed the tab, sure the gift is on its way. There is no rollback for that.

  • The result isn't the client's to predict. The bank decides: insufficient funds, fraud checks, 3-D Secure challenges, stock running out.
  • Failures are common. Card declines happen often enough that you must design for them.
  • The user acts on success. They leave, or tell someone. Money and promises follow from the confirmation screen.
  • Duplicates are expensive. A retry without an idempotency key can charge twice. Payment APIs such as Stripe's accept an Idempotency-Key header for exactly this reason.

So checkout waits for the server, shows progress, blocks double submits, and uses an idempotency key. Around the payment, optimism is still fine: cart changes can update at once, as the shopping cart guide shows.

The middle ground: delay the real action

For actions that are common to regret, a third pattern helps: show the result at once, but delay the real request a few seconds and offer Undo. Gmail's Undo Send holds the message for 5, 10, 20, or 30 seconds (you choose in settings) before sending it. Many apps delete the same way: the item disappears with an Undo toast, and the request goes out when the toast closes.

Decision How should a write show up on screen?

Pessimistic: wait for the server

Disable the control, show progress

Update the screen only from the server's answer

Pros

  • Never shows anything untrue
  • Simplest code; no rollback

Cons

  • Every action costs a round trip of waiting
  • Users double-click if feedback is weak

Optimistic: show at once, roll back on failure

Apply the change locally, send the request

Reconcile on success, roll back and explain on failure

Pros

  • Feels instant on any network
  • Users can do many things quickly

Cons

  • Rollback, ordering, and id code to get right
  • Wrong if the server decides the result

Delayed commit with Undo

Apply the change locally, start a timer of a few seconds

Send the request when the timer ends unless the user pressed Undo

Pros

  • Feels instant and gives a real undo
  • Cancelling costs nothing: no request was sent

Cons

  • Lost if the tab closes during the delay
  • Other users see the change a few seconds late

What we pick: Optimistic for small, predictable, harmless actions (likes, ticks, reorders, chat). Delayed commit for regrettable ones (send, delete, archive). Pessimistic when the server decides or the user will act on the result (payments, bookings, sign-up, anything legal or financial).

Check your understanding · Exercise 5

Which of these should NOT be optimistic? Select all that apply.

In real products and libraries

You rarely write optimistic plumbing from scratch. Every popular data library has a way to do it, and they fall into the same three rollback styles from chapter 4. Knowing which style a library uses tells you where its sharp edges are.

From snapshot to rebase. The more a tool keeps guesses apart from server data, the better it copes with overlapping changes.
ToolHow you write the guessHow it rolls back
TanStack QuerysetQueryData in onMutate, or render from variables while pendingSnapshot you return from onMutate, restored in onError; or nothing to undo with the variables style
SWRmutate(key, update, { optimisticData })rollbackOnError restores the previous data (on by default)
RTK QueryupdateQueryData inside onQueryStartedThe returned patch has an undo(): an inverse patch, applied on error
Apollo ClientoptimisticResponse on the mutationOptimistic data sits in a separate layer over the cache and is dropped when the real answer arrives
React 19useOptimistic inside an ActionThe optimistic layer ends with the Action; the base value shows again
Sync engines (Replicache, Zero)Mutators run locally first, then on the serverPending mutations are rebased on each new server state

The lesson on server-state libraries goes into caching and refetching; for optimistic writes, the main thing to check is whether the library keeps the guess apart from server data (Apollo, useOptimistic, sync engines) or writes it into the same cache (TanStack Query's setQueryData, SWR, RTK Query). With the second kind, overlapping changes need the extra care from chapters 4 and 6.

Patterns in well-known products

  • Social feeds (likes, reposts, follows): instant with no hint; the server's count replaces the guess. The Twitter guide in this course builds this.
  • Chat (WhatsApp, Slack): the message appears at once with a pending marker, keyed by a client id and matched to the server's echo. Failed messages stay, with retry.
  • Task boards and issue trackers (Trello, Linear): moves and edits apply locally at once. In talks about its sync engine, Linear has described its client as keeping a local copy of the data that syncs with the server in the background, so almost every action is optimistic by design.
  • Shopping carts: quantity changes are optimistic; prices, stock, and checkout are confirmed by the server. The shopping cart guide in this course draws that line.
  • Email (Gmail): archive and delete are instant with an Undo toast; send uses a delayed commit.

Where this ends up: local-first

Push the two-layer model far enough and you get a sync engine. Every read comes from a local store, every write goes to the local store first and into a queue of pending mutations, and a background process sends the queue and receives server changes. The UI never waits for the network at all, and "optimistic" stops being a special case: it is just how every write works.

The architecture of a sync engine. In the client: the UI reads only from a local store kept in IndexedDB, and writes go to a pending queue of mutations. A sync process sends the queue to the server's sync API and receives pushed changes, writes them into the local store, and rebases pending mutations on top. On the server, the sync API applies mutations to the database and reports which mutation ids it has applied.ClientUIreads only from the local storeLocal storeIndexedDBPending queuemutationsSync processsend queue, apply pushes, rebasereadswritesrebaseServerDBSync APIapplied mutation idssendpush
The UI never waits for the network: reads come from the local store and writes go to the queue. Optimistic updates stop being a special case and become how every write works.

The price is real infrastructure: a local database, a server that reports which mutations it applied, conflict rules, and migrations on both sides. For most products, library-level optimistic updates on the few actions that need them are enough.

Think first

Your team uses SWR. A settings page has ten toggles, and users flip several within a second. Some users see toggles flip back and forth. What is going on, and what would you change?

Using it in a system design interview

Optimistic updates come up in almost every interactive product question: a news feed, a chat, a to-do or Kanban board, a shopping cart, a comment section. Interviewers rarely ask for them by name. They ask "how does the like button work?" or "what happens if the send fails?", and see whether you bring them up.

When to bring it up

  • When you define the interactions in the requirements: "likes and comments should feel instant, even on slow mobile networks" is a non-functional requirement worth writing down.
  • When you design the data layer: say which mutations are optimistic, which are not, and why.
  • When you talk about failure handling: rollback, retries, idempotency, and what the user sees.

What to draw

Draw the client store in two parts, server data and a pending mutations queue, with arrows from UI to queue, queue to API, and API back into server data. Mark where rollback happens. Thirty seconds of drawing shows you understand the model, not just a library call.

How deep to go, by level

LevelWhat a good answer covers
JuniorUpdate the UI first, send the request, revert and show an error on failure. Disable double submits.
Mid-levelAll of the above, plus cancelling stale refetches, reconciling with the server's answer, temporary or client ids for creates, and which actions should not be optimistic.
SeniorOverlapping mutations and why snapshot rollback breaks, per-item ordering, idempotency keys for retries, conflict detection with versions, pending and failure UX including screen readers.
StaffWhen to move from per-mutation optimism to a sync engine, the server contract that needs (client ids, applied mutation ids, versions), offline queues, and the cost of building and running it.

Check your understanding · Exercise 6

You're designing a comment section. The interviewer asks what happens if the user posts a comment and loses signal. Which points belong in a strong answer? Select all that apply.

Summary

  • An optimistic update shows the result of an action at once and sends the request after. It turns a wait of hundreds of milliseconds on mobile into an instant response for the common case.
  • Keep confirmed server data apart from pending changes, and derive the screen from both. Most bugs come from mixing the two.
  • Snapshot rollback is fine for one change at a time; overlapping changes need an inverse per change or, better, a rebase on confirmed state.
  • The server's answer always wins. Cancel or ignore stale reads while a write is pending, and replace the guess with the real data on success.
  • For creates, let the client choose the id (a UUID) if you can. Otherwise keep a stable client key and queue follow-up actions until the server id arrives.
  • Send absolute values, keep one write in flight per item, and use idempotency keys so retries and reordering can't corrupt data.
  • Show pending and failed states in proportion to the user's effort, announce rollbacks to screen readers, and never silently drop typed work.
  • Don't be optimistic when the server decides the outcome or the user acts on it: payments, bookings, and sign-ups wait for the server.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Walk me through an optimistic like button, including failure.

Question 2

Why can rolling back to a snapshot be wrong, and what do you do instead?

Question 3

How do you handle creating an item optimistically when the server assigns ids?

Question 4

The user likes and unlikes quickly, and the server ends up liked. Why, and how do you fix it?

Question 5

When would you not use optimistic updates?

Practice it

These design problems lean on optimistic updates.

References

Read next