Optimistic UI: Updating Before the Server Answers
Show the result of a click before the server confirms it, then reconcile or roll back. Which actions suit it, and how to survive races, refetches and retries.
An optimistic UI applies the result of a user action to the screen immediately, sends the request in the background, and only then learns whether the server agreed. If it did, nothing visible happens; if it did not, the client rolls the screen back to a snapshot it took before the change and tells the user. The click feels instant because the UI is guessing, and every hard part of the pattern is about what happens when the guess is wrong or arrives out of order.
Context
For most of the web's history a click meant a round trip: submit, wait, re-render with whatever the server returned. Ajax-era apps like Gmail started updating the page before the server replied, and the idea got a name and a framework in Meteor's "latency compensation" (around 2012), which ran the same mutation on client and server and patched the difference. GraphQL clients made it a first-class API: Apollo Client's optimisticResponse and Relay's optimistic updaters. TanStack Query (React Query) documents it as a recipe around onMutate, and React 19 (stable since December 2024) ships a built-in hook for it, useOptimistic.
You have seen it every time a heart turns red the instant you tap it, a chat message appears in the thread with a small clock icon before the ticks arrive, or a card stays where you dropped it on a Kanban board. The smallest possible version is three lines and already shows the shape: change local state first, then ask the server.
async function like(post: Post) {
post.liked = true // 1. show it now
render()
await fetch(`/api/posts/${post.id}/like`, {method: 'POST'})
// 2. ...and hope. No rollback, no race handling yet.
}- Optimistic update
- Writing the expected result of a mutation into client state before the server has confirmed it.
- Snapshot
- A copy of the affected state taken just before the optimistic write, kept so it can be restored.
- Rollback
- Restoring the snapshot (or the last server-confirmed value) after the request fails.
- Reconciliation
- Replacing the guess with what the server actually returned: real IDs, timestamps, normalised values.
- Pending state
- A visual hint that an item is not confirmed yet, such as reduced opacity or a "sending" label.
Why it matters
Usability research has put the threshold for "feels instantaneous" at about 0.1 seconds since Nielsen's 1993 response-time limits, and a mobile round trip alone often takes three times that. Optimistic updates are how small, frequent actions stay under the threshold without a faster network. Done carelessly they produce a UI that lies: a like that silently disappears, a toggle that flickers back during a background refetch, or a duplicated comment after a retry. Most of the bugs are races between the optimistic write, the request and other updates to the same data.
The lifecycle: guess, send, reconcile
Every correct implementation follows the same four steps. The UI state and the server state briefly diverge; the pattern is about making sure they converge again, whichever way the request ends.
- 1Snapshot. Copy the state you are about to change, and stop anything that could overwrite it in the meantime, such as an in-flight refetch of the same data.
- 2Apply. Write the expected result into client state so the UI re-renders immediately, optionally with a pending style.
- 3Send. Fire the request. Send absolute state (
favorite: true) rather than a relative command ("toggle") so a retry or a reordered request cannot flip the value twice. - 4Settle. On success, reconcile with the server's response. On failure, restore the snapshot and tell the user. Either way, refetch afterwards so the cache ends up matching the server.
With TanStack Query (version 5, released October 2023) the four steps map onto the mutation callbacks. onMutate runs before the request and its return value becomes the context handed to onError, which is where the snapshot travels.
import {useMutation, useQueryClient} from '@tanstack/react-query'
type Topic = {slug: string; favorite: boolean}
const key = ['topics']
export function useToggleFavorite() {
const qc = useQueryClient()
return useMutation({
mutationFn: (t: Topic) =>
api.put(`/topics/${t.slug}/progress`, {favorite: !t.favorite}),
onMutate: async (t) => {
await qc.cancelQueries({queryKey: key}) // 1. no overwrite
const previous = qc.getQueryData<Topic[]>(key) // 1. snapshot
qc.setQueryData<Topic[]>(key, (list = []) => // 2. apply
list.map(x => x.slug === t.slug
? {...x, favorite: !x.favorite}
: x))
return {previous}
},
onError: (_err, _t, ctx) => {
qc.setQueryData(key, ctx?.previous) // 4. roll back
toast.error('Could not save. Please try again.')
},
onSettled: () => qc.invalidateQueries({queryKey: key}), // 4. resync
})
}What to make optimistic
The pattern pays off when the request almost always succeeds and a wrong guess is cheap to undo. It is the wrong tool when the server is the one deciding the outcome, or when the action cannot be taken back once the user believes it happened.
| Action | Optimistic? | Why |
|---|---|---|
| Like, favorite, toggle | Yes | Near-100% success; the client already knows the final state; undo is one write |
| Reorder, rename, mark read | Yes | Reversible, fully described by client state |
| Send a message, add a comment | Yes, with a pending style | Needs a temporary ID and a visible "sending" state until confirmed |
| Unique username, coupon code | Partly | Only the server knows the constraint; validate before showing success |
| Payment, order, delete account | No | Irreversible or decided by the server; show progress, not a guess |
Items whose ID comes from the server
Creating something is harder than updating it, because the server usually assigns the ID. The list needs a key now, and later actions on the new item (edit, delete) need an ID before the real one exists.
- 1Give the new item a temporary ID such as
tmp_{uuid}and render it with a pending style. - 2When the response arrives, replace the temporary item with the server's version. Keep a
tmp → realmap for actions the user took meanwhile, or queue those actions until the ID is known. - 3If the request fails, remove the item or keep it in a failed state with a retry button, as chat apps do, rather than dropping the user's text.
Races, refetches and rapid clicks
The success and failure paths above assume one request at a time. Real users click twice, networks reorder responses, and background refetches run whenever a window regains focus. Three races cause almost every optimistic-UI bug.
Out-of-order responses. Tag each request and let only the newest one per item update the screen, or abort the older request with an AbortController. The server needs the same discipline: absolute values plus a version or timestamp, so an old request that arrives late cannot overwrite a newer one.
Rollback to the wrong value. If request 2 fails after request 1 succeeded, restoring "the snapshot from before request 2" is right, but restoring "the snapshot from before the first click" undoes a write that succeeded. Roll back to the last value the server confirmed, and only from the latest request.
Refetch overwrites the guess. A refetch that started before the click returns the old value and replaces the optimistic one, so the heart flickers off and on. That is what cancelQueries in onMutate prevents; the refetch in onSettled then runs after the write.
const confirmed = new Map<string, boolean>() // last value server acked
const latest = new Map<string, number>() // newest request per item
export async function setFavorite(slug: string, next: boolean) {
const id = (latest.get(slug) ?? 0) + 1
latest.set(slug, id)
ui.set(slug, next) // optimistic
try {
const saved = await api.put(slug, {favorite: next})
confirmed.set(slug, saved.favorite)
if (latest.get(slug) === id) ui.set(slug, saved.favorite)
} catch {
if (latest.get(slug) !== id) return // a newer click owns the UI
ui.set(slug, confirmed.get(slug) ?? !next)
toast.error('Could not save your change.')
}
}useOptimistic, retries and offline
React 19's useOptimistic takes a different approach from a cache library. It does not store the guess anywhere: it returns a value that equals the real state, except while an action (an async transition) is running, when it equals whatever you last set. When the action finishes, the hook falls back to the real state. Success therefore needs the real state to have changed (for example a server action revalidated the data), and failure rolls back with no code at all.
'use client'
import {useOptimistic, startTransition} from 'react'
export function LikeButton({liked, save}: {
liked: boolean // real, from the server
save: (next: boolean) => Promise<void> // e.g. a server action
}) {
const [shown, setShown] = useOptimistic(liked)
const toggle = () =>
startTransition(async () => {
setShown(!shown) // instant
try {
await save(!shown) // revalidates 'liked'
} catch {
toast.error('Could not save.') // shown snaps back
}
})
return (
<button aria-pressed={shown} onClick={toggle}>
{shown ? '♥' : '♡'}
</button>
)
}Retries. Optimistic UIs hide latency, so they also hide failures that a retry would fix. Retrying is safe for absolute updates and dangerous for creates: send an idempotency key (or the client-generated ID) with every create so the server can recognise a repeat and return the original result instead of inserting twice.
Offline. Taken further, an optimistic UI becomes offline-first: the client keeps a persisted queue of pending mutations, replays them in order when the network returns, and has to resolve conflicts with changes made elsewhere in the meantime. TanStack Query can pause and persist mutations for this; beyond simple toggles, conflict handling is a design problem of its own (last-write-wins, field-level merge, or a CRDT (conflict-free replicated data type)).
Pitfalls
- Silent rollback
Reverting the heart without a word looks like a glitch, and if the user has scrolled away they never notice their action was lost. Pair every rollback with a visible message near the item or a toast, and for creates keep the failed item on screen with a retry button.
- Not cancelling in-flight refetches
A refetch that started before the click returns stale data after the optimistic write and replaces it, so the UI flickers back to the old value until the next refetch. Cancel queries for the affected data in the snapshot step, and refetch only once the mutation has settled.
- Relative commands on the wire
Sending "toggle" or "increment" means a retry or a duplicated request changes the value twice, and reordered requests produce a state nobody asked for. Send the absolute target value, and have the server write absolute state so repeats are harmless.
- Rolling back over a newer success
With several requests in flight, a failure from an older one restoring its snapshot can undo a later request that already succeeded. Track the latest request per item, ignore responses from superseded ones, and roll back to the last server-confirmed value.
- Optimistic success for decisions the server makes
Showing "Order placed" or "Username available" before the server has decided means showing something that may be false and cannot be quietly undone. Use a pending state for those and reserve optimistic updates for actions whose outcome the client already knows.
Interview questions
Q1What is optimistic UI, and when would you not use it?
It is updating the screen with the expected result of an action before the server confirms it, then reconciling or rolling back. I use it for frequent, reversible actions that almost always succeed, like likes, toggles and reorders. I avoid it where the server decides the outcome or the action is irreversible, such as payments or account deletion, and show a pending state instead.
Q2Walk me through implementing an optimistic like button with React Query.
In onMutate I cancel in-flight queries for the post so a refetch cannot overwrite my write, snapshot the cached post, set the cache to the liked state and return the snapshot as context. The mutation sends the absolute value. In onError I restore the snapshot and show a message, and in onSettled I invalidate the query so the cache ends up matching the server either way.
Q3What happens when the user clicks like and unlike quickly and the responses come back out of order?
A naive client applies each response as it arrives, so the final state is whatever response came last, which may be the opposite of the user's last click. I tag requests per item and let only the newest update the UI, or abort superseded requests. On the server, absolute values with a version or timestamp stop a late old request from overwriting a newer one.
Q4How do you optimistically add an item when the server assigns its ID?
I insert it with a temporary ID and a pending style, then swap in the server's item when the response arrives, mapping the temporary ID for any action taken meanwhile. If I control the API, I let the client generate a UUID instead, so there is nothing to reconcile and the ID also serves as an idempotency key for retries.
Q5Is optimistic UI related to optimistic locking?
Only by name. Optimistic locking is server-side concurrency control: write without a lock and reject the write if a version check shows someone else changed the row. Optimistic UI is a client rendering choice. They meet when the server uses a version check to reject a stale optimistic write, which the client then has to roll back.
Q6How does React 19’s useOptimistic differ from setting state yourself?
useOptimistic keeps no state of its own: it shows your optimistic value only while an action is running and falls back to the real prop or state when the action ends. Rollback on failure is therefore automatic, and success requires the real state to change, typically through revalidation after a server action. With manual state you have to snapshot, restore and resync yourself.
Q7How would you extend optimistic updates to work offline?
Persist the queue of pending mutations, keep applying them optimistically, and replay them in order when the connection returns, each with an idempotency key so replays are safe. The hard part is conflicts with changes made elsewhere, which needs an explicit policy: last-write-wins for simple fields, field-level merges, or CRDTs for collaborative data.
- Optimistic UI shows the expected result at click time and converges with the server afterwards: snapshot, apply, send, then confirm or roll back.
- Use it for frequent, reversible actions that almost always succeed; use a pending state when the server decides or the action cannot be undone.
- Cancel in-flight refetches before the optimistic write and refetch after the mutation settles, or stale data overwrites the guess.
- Send absolute values, let only the latest request per item touch the UI, and roll back to the last server-confirmed value.
- Never roll back silently; for creates, use client-generated IDs or temporary IDs plus idempotency keys for retries.
- React 19's useOptimistic shows the guess only during an action and falls back to real state automatically.