CachingGENERALFRAMEWORK-SPECIFICSIMPLIFIED

Rollback and Reconciliation

The server rarely just says yes or no. It says something slightly different — and reverting your prediction is usually the wrong answer to that.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

The server accepted my mutation and returned something other than what I predicted. Now what?

The user intent

Someone renamed a folder. The server accepted it, trimmed the trailing space, appended "(2)" because the name was taken, and stamped a new modified time. The user should end up seeing what is true, and should not have their own text replaced without being told.

The obvious build

Two branches. On success, keep the optimistic value — it worked. On failure, restore the snapshot and show an error.

Why it breaks

Success is not binary. Server-assigned ids, normalised strings, computed totals, server clocks and conflict-resolved values all mean "accepted, but different". Keeping the prediction leaves the cache confidently wrong until something refetches it (The Client Cache Model).

How it breaks in a real browser
  • Success is not binary. Server-assigned ids, normalised strings, computed totals, server clocks and conflict-resolved values all mean "accepted, but different". Keeping the prediction leaves the cache confidently wrong until something refetches it (The Client Cache Model).
  • The snapshot restored on failure is the entry as it was when the mutation started. If a revalidation or a second mutation changed it since, the rollback also reverts work that had nothing to do with the failure (Stale-While-Revalidate).
  • Rolling back a whole entity to undo one field discards concurrent changes to the other fields — a lost update the client caused itself (The Lost Update, Step by Step).
  • A 409 conflict, a 422 validation error, a 403 and a 500 all collapse into "something went wrong", so the user is told there is a problem and given no way to act on it (The Error Model: Structure Over Apology).
  • A transport failure has an unknown outcome. The request may have been applied. Rolling back is a guess, and it is wrong roughly as often as the timeout happened after the write (Retries, and the Duplicate Order).
  • Reverting text the user typed, without telling them, is data loss with a smooth animation over it.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • There are four outcomes, not two: accepted-as-predicted, accepted-with-differences, rejected-with-a-reason, and unknown. Each has a distinct correct response, and collapsing them is where the pattern goes wrong.
  • Reconciliation means writing the authoritative fields from the response over the predicted ones, keeping fields you never predicted, and preserving node identity so the DOM updates rather than rebuilding (Node Identity Across Updates).
  • If the mutation response is the full resource, write it in. If it is a patch, merge it. Writing a partial response over a full entity is how fields disappear from a cache with no error anywhere (PUT vs PATCH).
  • Rejections fall into classes with genuinely different handling: validation (the user can fix it, keep their input), conflict (someone else changed it — you need the current value, not a revert), authorization (the user never could, and the UI implied otherwise), and transport (outcome unknown).
  • Detecting a conflict at all requires a version: an ETag with If-Match, or a version field the server compares. Without one, the client cannot distinguish "changed" from "changed by me", and last-write-wins is what you get whether you chose it or not (Optimistic Concurrency: Versions and If-Match, Conditional Requests: ETags, 304 and 412).
  • For the unknown case, an idempotency key converts "retry might duplicate" into "retry is safe", which turns an unanswerable question into a retry. Without one, the only honest move is to refetch and look (Idempotency Keys: The Mechanism).

What this makes the browser do

And which of it is avoidable.

  • A reconcile is one more write and one more render. When the response is deep-equal to the prediction and references are preserved, it costs nothing; when a key changed, it destroys and recreates the node (Reconciliation and Keys).
  • A rollback is a third render of the same region, and it is the one that most often changes geometry — restoring a removed row, restoring a longer string — so it costs layout as well (The Cost of a Change).
  • A refetch after an unknown outcome is a full request plus parse plus render, on a path the user is already waiting on.
  • Conflict UI is new DOM: a comparison, two labelled choices, focus management. It is the most expensive path and the least exercised (Accessible Component Patterns).

Revert, or reconcile

The instinct after a mutation is to think in terms of undo: it failed, so put things back. That instinct is right for exactly one of the four outcomes and actively harmful for the others. Reverting on a difference throws away the server's correction; reverting on an unknown outcome contradicts a change that may have been made; reverting the whole entity to undo one field destroys concurrent edits.

The better default is to think in terms of replacing your guess with what you were told. On success, what you were told is the response. On rejection, what you were told is a reason, and the snapshot is a fallback rather than the answer. On unknown, you were told nothing, so the honest move is to go and look.

The same mutation, two mental models
Undo on failure, keep on success
try {
  await save(next)
  // success: the optimistic value stays.
  //   server trimmed the name        -> cache is wrong
  //   server assigned a real id      -> cache holds "tmp-8f21"
  //   server stamped updatedAt       -> cache holds our guess
  //   server appended "(2)"          -> user never finds out
} catch {
  cache.write(key, snapshot)
  // failure: revert everything.
  //   a revalidation landed since    -> reverted too
  //   the user edited another field  -> reverted too
  //   the request actually succeeded -> now client and server disagree
  toast('Something went wrong')
}
Replace the guess with what you were told
const outcome = await attempt(save, next)

switch (outcome.kind) {
  case 'accepted':
    cache.write(key, outcome.server)          // authoritative for every field
    if (differsVisibly(next, outcome.server))
      announce(explain(next, outcome.server)) // "Saved as Reports (2)"
    break

  case 'conflict':
    cache.write(key, outcome.current)         // the server's current value
    openConflict({ mine: next, theirs: outcome.current })
    break                                     // the user chooses; we do not

  case 'rejected':
    cache.restoreFields(key, snapshot, changedFields)  // only what we changed
    showInlineError(outcome.reason)           // next to the control, keeps input
    break

  case 'unknown':
    await cache.refetch(key)                  // we do not know. Go and look.
    break
}

The four branches are not defensive padding; they are four genuinely different states of the world, and each one has a different correct value to display. Collapsing them into two forces the client to guess in the two cases where it has been given information — the differed case, where the server told you the answer, and the conflict case, where the server told you someone else got there first.

Where rollback goes wrong

Every row here is a rollback that executed exactly as written and still made things worse. That is the character of this lesson: the bugs are not in the error handling being missing, they are in the error handling being simplistic about what "before" means.

The last two rows are failures of the fix itself — a conflict UI nobody reads, and a reconciliation that fires while the user is still typing.

Rollback and reconciliation in production
TriggerSymptomCauseResponse
A revalidation resolves between mutation send and rejectionThe failed rename also reverts a status change that a colleague made and that had already arrivedThe snapshot is the entry at mutation time, and the entry has moved on sinceRestore only the fields you changed, and prefer a refetch over a wholesale restore when the entry has been written since the snapshot was taken.
The mutation response is a 204 with no bodyThe optimistic value stays forever; the real id, timestamp and normalised text never arriveThere is nothing to reconcile with, so the prediction becomes the cached truthAsk for the updated resource in the response, or invalidate the entry so the next read fetches it (Response Contracts Are Not Database Rows).
The response is a partial patch and is written wholesaleFields vanish from the UI: the row renders with empty columns after a successful saveA partial object replaced a full entity in the cacheMerge patches, replace only full resources, and make the shape of the response part of the endpoint's contract rather than a per-call surprise.
The request times out after the server applied itThe user sees their change disappear, retries, and creates a duplicateUnknown was treated as failure, and the retry was not idempotentSend an idempotency key so retry is safe, and refetch rather than roll back on transport failures (Idempotency Keys: The Mechanism).
A 409 is retried automatically with backoffA burst of failing requests and a spinner that never resolvesRetry logic that classifies by "did it fail" rather than by whyRetry transport and 5xx failures only; a conflict needs a decision, not another attempt (Retries and Timeouts as Contract Guidance).
A conflict dialog offers "keep mine" and "keep theirs"Users always keep theirs; the other person's edit is lost as reliably as with no dialog at allThe dialog asks a question without showing what the answer costsShow the actual differing values, name the other author and the time, and offer a merge where the data allows one (Resynchronisation After a Gap).
Reconciliation writes into a field that still has focusThe caret jumps to the end mid-word, or the user's last few characters are erasedThe server value replaced a controlled input the user was still editingDo not reconcile a field while it is focused and dirty; hold the value and apply it on blur, announcing the change (Form State Is a Draft).

What the response is allowed to change

A useful discipline before writing any optimistic mutation is to list which fields you are predicting and which fields only the server can produce. That list is short, it is stable per endpoint, and it tells you immediately whether the prediction is safe and what the reconciliation has to do.

The matrix below is that list generalised. The right-hand column is the part worth arguing about on a team: when the server's value differs from what the user typed, saying nothing is a choice, and it is usually the wrong one.

What the response carriesWho could know itWhat reconciliation must do
The field the user changed, unchangedBothWrite it anyway. Comparing and skipping is fine; skipping without comparing is a bug.
The field, normalised (trimmed, cased, formatted)Server onlyWrite it and tell the user their input was adjusted — silently replacing typed text is the worst option available.
A server-assigned id replacing a temporary oneServer onlyWrite it while preserving node identity, or the row the user just created is destroyed and rebuilt and takes focus with it.
A server timestamp or versionServer onlyWrite it. The version is what makes the *next* conflict detectable (Optimistic Concurrency: Versions and If-Match).
A derived total, count or rankServer onlyWrite it, and invalidate the other queries that display it — they were not part of this response (Derived State).
A disambiguated value ("Reports (2)")Server onlyWrite it and explain it. This is a decision the server made on the user's behalf and they will act on it.
The current value, on a 409 conflictServer onlyWrite it, then ask the user to choose. Do not merge silently and do not discard either side.
A validation reason, on a 422Server onlyKeep the user's input, restore only the cache, and put the reason next to the control (Errors People Can Actually Perceive).
Nothing at all — the request never completedNeitherRefetch. Any other answer is a guess presented as a fact.
Four outcomes as a type, so none can be forgotten
1type Outcome<T> =
2 | { kind: 'accepted'; server: T }
3 | { kind: 'conflict'; current: T; changedBy?: string }
4 | { kind: 'rejected'; reason: 'validation' | 'authorization'; detail: string }
5 | { kind: 'unknown' } // transport failed; it may have applied
6
7async function attempt<T>(send: () => Promise<Response>, body: T): Promise<Outcome<T>> {
8 let res: Response
9 try {
10 res = await send()
11 } catch {
12 return { kind: 'unknown' } // network error, abort, tab suspended
13 }
14
15 if (res.ok) return { kind: 'accepted', server: (await res.json()) as T }
16
17 switch (res.status) {
18 case 409: {
19 const { current, changedBy } = await res.json()
20 return { kind: 'conflict', current, changedBy }
21 }
22 case 422:
23 case 400:
24 return { kind: 'rejected', reason: 'validation', detail: await res.text() }
25 case 401:
26 case 403:
27 return { kind: 'rejected', reason: 'authorization', detail: await res.text() }
28 default:
29 return { kind: 'unknown' } // a 5xx may or may not have applied
30 }
31}

Modelling this as a discriminated union rather than a try/catch is what makes the fourth case unforgettable: an exhaustive switch will not compile until unknown is handled, and unknown is the branch every hand-written implementation omits (TypeScript in the Build).

How to build it

Most important first.

  • Ask the API to return the updated resource in the mutation response. Reconciliation then becomes a cache write instead of a refetch, and the entire class of "predicted value never corrected" disappears (How API Shape Drives UI Complexity).
  • Always write the response back, even when it looks identical to your prediction. Comparing and skipping is an optimisation; skipping without comparing is a bug.
  • Snapshot at the granularity you mutate. If you changed one field, restore one field — restoring the whole entity is what turns a failed rename into a lost edit elsewhere.
  • Classify the error before responding to it, and give each class its own affordance: fix, choose, explain, or retry. "Something went wrong" is the absence of this step (Errors People Can Actually Perceive).
  • When the reconciled value visibly differs from what the user typed, say so in words: "Saved as Reports (2) — that name was taken." Silently swapping the text is the version of this that generates support tickets.
  • On conflict, show the current server value alongside theirs and let them choose. Never pick silently, in either direction.
  • On an unknown outcome, refetch rather than assume. The cache should say "I do not know" for one round trip rather than guess for the rest of the session.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A rollback changes content the user believes they authored. Announce it assertively and place the message next to the control, so the correction is heard before the user acts on the stale belief (Live Regions and Announcement).
  • A reconciliation that changes a value the user typed must be announced and explained. The visual diff is invisible to a screen-reader user, who will keep believing the value they entered (Errors People Can Actually Perceive).
  • Keep focus where it was through predict, reconcile and rollback. Three writes to the same region are three chances to destroy the focused node (Focus Management, Node Identity Across Updates).
  • Conflict resolution is a decision surface and needs real semantics: a named region, a heading, two distinctly-labelled actions ("Keep my version", "Use the current version"), and a keyboard path through all of it. Two icons and a hover tooltip is not a decision surface (Accessible Component Patterns, Semantics Before ARIA).
  • Do not use disabled on the retry control while retrying. aria-busy plus aria-disabled keeps it reachable so its state can be heard (The Rules of ARIA).

What can go wrong

Failure modes
  • Rollback of a stale snapshot, reverting a concurrent change that had nothing to do with the failed mutation.
  • A partial response written over a full entity, silently dropping every field the response did not mention.
  • The duplicate row: an optimistic item under a temporary id, plus the same item under its real id from a refetch that arrived in between (Optimistic UI).
  • An auto-retry on a rejected mutation, turning a 409 into a loop that hammers the endpoint and never succeeds (Retries, and the Duplicate Order).
  • Reconciliation that reformats the user's input mid-typing, because the response landed while the field still had focus and a caret position.
  • The mitigation failing: you build a conflict-resolution dialog, and users choose "keep mine" every time without reading it, which is last-write-wins with extra steps and a false sense of safety.
What can arrive out of order
  • A revalidation resolves between the mutation being sent and its response, so the snapshot the rollback would restore is already older than the entry it would overwrite.
  • Two mutations on the same entity overlap. The second's reconciliation lands first, then the first's response overwrites it with an older server state (Out-of-Order Responses).
  • The user edits the same field again while the first mutation is in flight, so the reconciliation replaces text they are actively typing.
  • A rejection and a background refetch arrive in the same tick; the rollback writes the snapshot and the refetch writes the server state, and which one wins depends on ordering nothing has declared (Reasoning About Races: A Method, Not an Instinct).
  • An unknown-outcome retry succeeds after the client already rolled back, so the server has the change and the client shows the old value (Idempotency Keys: The Mechanism).
Security
  • Server error text is server-controlled. Render it as text, never as HTML, and never through an innerHTML-shaped API — an error path is a sink like any other (Sanitization and Trusted HTML, Cross-Site Scripting).
  • Do not amplify an information leak in your copy. If the server distinguishes "not found" from "not permitted", the client repeating that distinction tells an unauthorized user that a resource exists. The client cannot fix the leak, but it can decline to advertise it (Broken Access Control (IDOR / BOLA)).
  • A rejection on authorization grounds means the interface offered something the server refuses. That is a UI bug to fix at the source, not an error to handle more gracefully (Authorization-Aware UI).
  • Never auto-retry a mutation whose rejection class you have not inspected. Retrying an authorization failure is noise; retrying a non-idempotent write on an unknown outcome is a duplicate side effect (Idempotency Keys: The Mechanism).
Misreads
  • "Success means the prediction was right." Success means it was accepted. The response is the authority for every field it contains, including the ones you happened to guess correctly (Optimistic UI).
  • "Rollback restores correctness." It restores a snapshot, which is a past state, not necessarily a current one. Correctness after a failure is a refetch; rollback is a fast approximation of it.
  • "A timeout means it failed." A timeout means you do not know. Treating unknown as failure is how a client rolls back a change the server made and keeps a user convinced their work vanished (Network Failures Only the Client Can See).
  • "Conflicts are rare enough to ignore." They are rare per user and routine per product, and the ones that matter cluster exactly where two people work on the same thing — which is most collaborative software.

Measuring it, and what changes in the field

How you would see this
  • Track outcomes by class — as-predicted, differed, rejected, unknown — per mutation type. The "differed" rate tells you which predictions are structurally wrong; the "unknown" rate tells you how much of your traffic needs idempotency (Frontend Error Tracking).
  • The Network panel shows the mutation's status code and its response body, which is where you check whether the server is actually returning the updated resource or just an acknowledgement (Debugging the Network).
  • Cache devtools before and after a mutation show what was written and whether fields were dropped by a partial merge (Debugging State).
  • In the field, a conflict rate that rises with concurrent editing tells you whether optimistic concurrency is a theoretical concern for this resource or a daily one (Real User Monitoring).
Slow device, slow network, large data, old tab
  • On a slow network, the window between prediction and reconciliation is wide, so more revalidations and more user edits land inside it, and stale snapshots become the common case rather than the rare one.
  • With multiple users editing the same records, conflicts move from a theoretical branch to a routine outcome, and the conflict UI stops being optional (Resynchronisation After a Gap).
  • Offline, every mutation resolves late and in a batch, so rollbacks and reconciliations arrive together, sometimes for records the user has since edited again (The Offline Mutation Queue).
  • On a slow device the three renders of a failed optimistic mutation are visible as three distinct frames, which makes the contradiction more obvious rather than less.
What this costs
  • Reconciling properly costs an API contract commitment — every mutation must return enough of the resource — and buys a cache that converges on the truth without a refetch.
  • Field-level snapshots cost more bookkeeping than entity-level ones and buy rollbacks that do not destroy concurrent work.
  • Real conflict handling costs a UI surface most teams never build, and buys the ability to say honestly that the product does not lose data. Last-write-wins costs nothing and loses data quietly.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe four outcomes and their distinct handling are a property of doing work over an unreliable network against an authority you do not control, so they apply to any client and any transport, including a service worker replaying a queue.
  • FRAMEWORK-SPECIFICTanStack Query hands you the snapshot yourself via the context returned from onMutate, so the granularity of the rollback is entirely your choice; Apollo Client and Relay discard the optimistic layer automatically when the real response is written to the normalised store, which makes rollback correct by construction but makes field-level partial rollback something you cannot express; SWR's rollbackOnError restores the previous value wholesale. The class of bug differs accordingly.
  • SIMPLIFIEDThe four-outcome model treats the server as a single authority with a single answer. Systems with asynchronous acceptance — a 202 with a job to poll, or a write that is replicated before it is readable — have a fifth state, "accepted but not yet visible", which needs its own UI (The Async Job Pattern).

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Performancetail-latency
Databasemvcc
Domains that do not exist yet
  • Distributed Systems — reconciliation is conflict resolution between a replica and its authority, and the choice between last-write-wins, a version check and a merge is the same choice a replicated store makes, with a person available to break the tie.
  • Software Design — modelling four outcomes as a closed union rather than a boolean is the design decision that makes the unknown case impossible to forget.