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.
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?
If the request is a toggle ("flip done"), two toggles cancel out and the to-do ends up not done. Even with absolute values, the app may read the second tap as "untick" and send done: false.
The root cause is that the screen showed the wrong state for almost a second. With an optimistic update the box is ticked at once, so there is no reason to tap again.
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.
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);
}
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.
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.
- 0 ms: the tap. The click handler runs. Nothing has gone to the network yet.
- 0 ms: stop stale reads. Cancel or ignore any in-flight refetch of this post, or its older answer could overwrite the guess.
- 0 ms: remember the way back. Record a pending change,
like:post-7. - 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. - 1 ms: send.
PUT /posts/7/likewith{ "liked": true }. It sets a value instead of flipping one, so a retry can't count twice. - 400 ms: the answer. The server returns
200with{ "likeCount": 57, "likedByMe": true }. Other people liked the post in the meantime, so the real count is 57, not 42. - 400 ms: reconcile. Write the server's post into confirmed state and drop the pending change. The screen shows 57 and a red heart.
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.
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?
No. The 42 was built from a count that was already out of date when the page loaded. Keeping it would show a wrong number forever, or until the next reload.
The jump is usually fine: the user watches the heart, not the count. What you must avoid is a jump back to 41, which reads as "your like didn't count".
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.
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?
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.
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"] });
},
});
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.
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>
);
}
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.
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_1would fail: the server has never heard oftmp_1. Queue the tick until the real id is known. - Remounting. If the row's React
keyis the id, swappingtmp_1fortodo_9f2cmakes 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_9f2cand the pendingtmp_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.
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" }
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.
| Question | Temporary id, swapped later | Client-generated UUID | clientId plus serverId |
|---|---|---|---|
| Row remounts on save? | Yes, unless the key is separate | No | No, the key is clientId |
| Edits before the create returns | Queue and rewrite ids | Send at once | Queue until serverId is set |
| Safe to retry the create? | Only with an idempotency key | Yes, same id | Only with an idempotency key |
| Server changes needed | None | Accept and validate client ids | None |
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.
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/likewith{ "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.
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.
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?
Put the to-do back where it was, so the list is honest again, and show a toast such as "Couldn't delete 'Buy milk'. It's back in your list." with a Retry button. Announce the same text in a polite live region.
Don't scroll back or move focus; the user may be doing something else. That is why the toast names the item.
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
- 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.
- 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.
- 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.
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-Keyheader 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?
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.
| Tool | How you write the guess | How it rolls back |
|---|---|---|
| TanStack Query | setQueryData in onMutate, or render from variables while pending | Snapshot you return from onMutate, restored in onError; or nothing to undo with the variables style |
| SWR | mutate(key, update, { optimisticData }) | rollbackOnError restores the previous data (on by default) |
| RTK Query | updateQueryData inside onQueryStarted | The returned patch has an undo(): an inverse patch, applied on error |
| Apollo Client | optimisticResponse on the mutation | Optimistic data sits in a separate layer over the cache and is dropped when the real answer arrives |
| React 19 | useOptimistic inside an Action | The optimistic layer ends with the Action; the base value shows again |
| Sync engines (Replicache, Zero) | Mutators run locally first, then on the server | Pending 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 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?
Each mutate with optimisticData keeps a snapshot, and a rollback or a revalidation landing mid-flight can restore data without the other toggles' changes: the snapshot problem from chapter 4.
Fixes: give each toggle its own key so caches don't overlap, or serialise writes and pause revalidation until the queue is empty, or send one "save settings" call with all values, one absolute write.
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
| Level | What a good answer covers |
|---|---|
| Junior | Update the UI first, send the request, revert and show an error on failure. Disable double submits. |
| Mid-level | All 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. |
| Senior | Overlapping mutations and why snapshot rollback breaks, per-item ordering, idempotency keys for retries, conflict detection with versions, pending and failure UX including screen readers. |
| Staff | When 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.
On tap, cancel any in-flight read of the post so it can't overwrite the guess, record a pending change, and show the post liked with the count plus one. Then send an absolute write, PUT /posts/7/like with liked: true, carrying an idempotency key.
On success, replace the post with the server's version, so the count becomes the real one. On a 4xx, drop the pending change so the post shows as before, and tell the user briefly, also through a polite live region. On a network error, retry with backoff using the same key and only roll back if retries run out.
Question 2
Why can rolling back to a snapshot be wrong, and what do you do instead?
A snapshot captures every value at one moment, including other pending changes and older server data. Restoring it on one failure undoes changes that are still in flight or already saved, so the screen jumps.
Keep confirmed server data apart from a list of pending changes, and derive the screen as confirmed data with pending changes replayed on top. On failure, remove just that change and recompute. Apollo's optimistic layer, React's useOptimistic, and sync engines all work this way. For simple cases, an inverse of just that change, or one write in flight per item, is enough.
Question 3
How do you handle creating an item optimistically when the server assigns ids?
Best: let the client generate the id with crypto.randomUUID() and have the server accept it. Nothing changes on save, and a retried create is naturally idempotent.
If the server must assign ids, keep a stable clientId for React keys and local lookups and a serverId filled in on success. Queue edits and deletes to the new item until serverId exists, and use an idempotency key so a retry doesn't create two items. Match server echoes and refetches to pending items by client id so they don't appear twice.
Question 4
The user likes and unlikes quickly, and the server ends up liked. Why, and how do you fix it?
The like and the unlike were in flight at the same time and the server processed them in the other order. Requests in flight together have no guaranteed order. With toggle requests there is a second risk: a retried toggle flips the state again.
Send absolute values instead of toggles, allow one write in flight per item, and collapse newer changes to the latest intent while one is in flight. Where the client can't serialise, add a per-item sequence number the server uses to ignore stale writes.
Question 5
When would you not use optimistic updates?
When the server decides the outcome (payments, bookings, usernames, coupon codes), when failures are common, or when the user or another system acts on the result before it's confirmed. Payments tick all three.
There I wait for the server with clear progress, block double submits, and use an idempotency key. For regrettable but harmless actions like delete or send, I'd use a delayed commit with Undo instead.
Practice it
These design problems lean on optimistic updates.
- Design a star rating widgetEasy
- Design a toast notification systemEasy
- Design a like button for millions of usersEasy
- Design a file-explorer tree viewMedium
- Design a drag-and-drop reorderable listMedium
- Design an Instagram-style stories viewerMedium
- Design a nested comments threadMedium
- Design a notification centerMedium
- Design an embeddable poll widgetMedium
- Design product ratings and reviewsMedium
- Design an e-commerce cart and checkoutMedium
- Design a real-time leaderboardMedium
- Design PinterestMedium
- Design Stack OverflowMedium
- Design a blogging platform like MediumMedium
- Design a grocery delivery slot pickerMedium
- Design an online chess gameMedium
- Design a gamified learning app like DuolingoMedium
- Design an AI image generation studioMedium
- Design a calendar week viewHard
- Design a Kanban boardHard
- Design a news feedHard
- Design undo and redoHard
- Design a chat appHard
- Design InstagramHard
- Design RedditHard
- Design LinkedInHard
- Design a TikTok-style vertical video feedHard
- Design the Spotify web playerHard
- Design AirbnbHard
- Design a seat selection map for eventsHard
- Design GmailHard
- Design Google CalendarHard
- Design Google DriveHard
- Design SlackHard
- Design a code review diff viewerHard
- Design a customer support ticket dashboardHard
- Design a collaborative pixel canvas like r/placeHard
- Design an offline sync engineHard
References
- TanStack Query: Optimistic Updates Both the cache and the
variablesstyles, with rollback. - React: useOptimistic The React 19 hook and how it interacts with Actions.
- SWR: Mutation and Revalidation
optimisticData,rollbackOnError, andpopulateCache. - Apollo Client: Optimistic mutation results A separate optimistic layer over the normalised cache.
- MDN: Crypto.randomUUID() Generating ids on the client.
- MDN: If-Match Conditional writes and
412 Precondition Failedfor conflict detection. - Linear: Scaling the Linear Sync Engine A talk on how Linear's client-side sync engine works.
- Nielsen Norman Group: Response Times, the 3 Important Limits The 0.1, 1, and 10 second limits behind the 100 ms budget.
- Stripe: Idempotent requests How a payment API makes retries safe.
Read next
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Background sync and queued writesSaving the user's writes in a durable queue when the network is gone, then sending each one exactly once when it comes back, even if the tab was closed: the write half of every offline-capable app.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.