Rolling Back Optimistic Updates on Server Error

An optimistic update applies a change to the UI the instant the user acts, before the server confirms it. That responsiveness is only safe if you can undo it cleanly when the server says no — otherwise a failed request leaves the island showing a state that never actually happened. This guide covers the snapshot-and-restore pattern for reverting an optimistic mutation inside a single hydrated island: capture the pre-mutation state, apply the delta, and on rejection restore precisely, without clobbering other mutations still in flight and without re-hydrating anything. It is the failure-path companion to optimistic updates without full hydration, which covers applying the delta in the first place.

Prerequisites


The Snapshot / Restore Lifecycle

Snapshot before the delta, show the optimistic state, and branch on the response. Success confirms; failure restores the snapshot and surfaces an error.

Optimistic mutation snapshot and rollback lifecycle Left to right: capture snapshot with a mutation id, apply the optimistic delta and re-render, send the mutation to the server. The response branches: success confirms and discards the snapshot; error restores the snapshot and shows an error affordance. 1 · Snapshot { id, prev } 2 · Apply delta UI updates now 3 · Send await server Success confirm · drop snapshot Error restore prev · show error

Implementation Steps

Step 1 — Snapshot before applying the delta

Goal: Keep an exact copy of the state you can restore, keyed by a mutation id.

// islands/LikeButton.tsx — a React island applying an optimistic like.
'use client';
import { useRef, useState } from 'react';

type Post = { id: string; liked: boolean; likes: number };

export default function LikeButton({ initial }: { initial: Post }) {
  const [post, setPost] = useState(initial);
  // Snapshots are keyed by mutation id so we can restore the RIGHT one even if
  // several are in flight. A ref, not state — it must not trigger re-render.
  const snapshots = useRef(new Map<string, Post>());

  async function toggleLike() {
    const mutationId = crypto.randomUUID();
    snapshots.current.set(mutationId, post);   // capture BEFORE mutating
    // …delta + send in the next steps
  }
  return <button onClick={toggleLike} aria-pressed={post.liked}>{post.likes}</button>;
}

Expected output: Each click stores a snapshot under a unique id before any visible change; the map grows by one per in-flight mutation.

Two details make this snapshot reliable. The map is held in a useRef, not useState, because writing a snapshot must not itself trigger a re-render — the snapshot is bookkeeping, not display state. And it is keyed by a per-mutation id rather than being a single “previous value” slot, because under optimistic updates several mutations can be in flight at once. A single-slot snapshot would be overwritten by the second click before the first request resolves, and a rollback would then restore the wrong baseline. The map lets each in-flight mutation carry its own restore point, which is what makes precise, isolated rollback possible later.

If the optimistic value lives in a shared store rather than local component state — the pattern from sharing state across islands — take the snapshot at the store level with the same id scheme, so a rollback reverts every island reading that value together rather than leaving them briefly inconsistent.


Step 2 — Apply the optimistic delta

Goal: Update the UI immediately so it reacts without waiting for the network.

// Inside toggleLike, right after snapshotting:
const optimistic: Post = {
  ...post,
  liked: !post.liked,
  likes: post.likes + (post.liked ? -1 : 1),
};
// Local write only — re-renders THIS island; no re-hydration, no other island.
setPost(optimistic);

Expected output: The button’s pressed state and count flip instantly on click, before the request resolves.


Step 3 — Send the mutation and guard against stale responses

Goal: Branch on the server result while ignoring out-of-order or superseded responses.

try {
  const res = await fetch(`/api/posts/${post.id}/like`, {
    method: 'POST',
    body: JSON.stringify({ liked: optimistic.liked, mutationId }),
  });
  if (!res.ok) throw new Error(`server rejected: ${res.status}`);

  // Success — reconcile with the authoritative value the server returns,
  // then drop the snapshot; it is no longer needed.
  const confirmed: Post = await res.json();
  setPost((cur) => (cur.id === confirmed.id ? confirmed : cur));
  snapshots.current.delete(mutationId);
} catch (err) {
  rollback(mutationId);   // see Step 4
}

Expected output: On a 200 the count settles to the server’s authoritative number; the snapshot for that id is removed.


Step 4 — Restore precisely on error

Goal: Revert only the failed mutation, preserving later ones, and surface an error.

function rollback(mutationId: string) {
  const prev = snapshots.current.get(mutationId);
  if (!prev) return;                       // already reconciled or rolled back
  snapshots.current.delete(mutationId);

  setPost((cur) => {
    // If no later mutation changed this post, restore the snapshot directly.
    // If later mutations exist, rebase: reapply their deltas onto `prev` so a
    // stale snapshot does not erase changes that landed after it was taken.
    return snapshots.current.size === 0 ? prev : rebase(prev, snapshots.current);
  });
  setError('Could not save your like — reverted.');   // visible affordance
}

Expected output: A forced server error returns the button to its exact pre-click state, an error message appears, and any other in-flight like is untouched.

The rebase branch is what separates a correct rollback from a naive one. Consider a field the user changed twice — mutation A then mutation B — where A fails while B is still pending. Blindly restoring A’s snapshot would erase B’s optimistic value along with A’s, because A’s snapshot predates B. Rebasing instead takes A’s restore point as the new base and reapplies the deltas of every mutation still in the map (here, B) on top of it. The result reflects “as if A had never happened, but B still did” — which is exactly the truth of the situation. For simple scalar fields you can often reverse a single delta instead of rebasing, but any field that accumulates (a list, a counter touched by concurrent actions) needs the rebase to stay correct under concurrency.

Crucially, none of this touches hydration. The rollback is a setPost call in an already-mounted island; the framework re-renders that one island and nothing else. No island re-mounts, no server round-trip is required to revert, and no other island on the page is disturbed — the responsiveness that justified the optimistic update in the first place is preserved even on the failure path.


A Snapshot Is More Than the Value Four things a rollback must restore: the value itself, the derived display state such as a count or a total, the control's own state including focus and disabled flags, and the layout position if the change altered the document height. Each is paired with what a visitor sees when that piece is forgotten. Restore all four or the rollback looks like a new bug the value · forget it and the change appears to have succeeded derived display state · forget it and the count and the list disagree control state — focus, disabled, pending flag · forget it and the button stays dead scroll anchoring · forget it and the page jumps when a removed row returns

Verification

  1. Exact-restore assertion. Force a rejection and assert the state deep-equals the pre-mutation snapshot:

    // Vitest — mock fetch to reject, then assert the UI reverts exactly.
    test('rolls back to pre-mutation state on 500', async () => {
      server.use(rest.post('/api/posts/:id/like', (_, res, ctx) => res(ctx.status(500))));
      const { getByRole } = render(<LikeButton initial={{ id: 'p1', liked: false, likes: 10 }} />);
      const btn = getByRole('button');
      fireEvent.click(btn);
      expect(btn).toHaveAttribute('aria-pressed', 'true');   // optimistic
      await waitFor(() => expect(btn).toHaveAttribute('aria-pressed', 'false')); // restored
      expect(btn).toHaveTextContent('10');                   // exact count back
    });
  2. Concurrent-mutation isolation. Fire two likes on different posts, fail one, and confirm the other keeps its optimistic value. The rollback must not touch the unrelated mutation.

  3. No re-hydration. Record the Performance panel across a rollback and confirm no island mount/hydrate task fires — only a local re-render of the button island.

  4. Error affordance. Confirm the failure surfaces a visible, accessible message (not just a console error) so the user knows the action did not persist.


Troubleshooting

Rollback undoes a later, successful mutation too

Root cause: The code restores a whole-store snapshot taken before mutation A, but mutation B succeeded in the meantime. Restoring A’s snapshot blindly erases B’s committed change.

Fix: Key snapshots by mutation id and rebase on rollback — restore the failed mutation’s base, then reapply the deltas of any mutations still tracked in the snapshot map. Never restore a global snapshot when other mutations may have landed after it.

return snapshots.current.size === 0 ? prev : rebase(prev, snapshots.current);
A slow error response reverts a value the user has since changed again

Root cause: Mutation A’s rejection arrives after the user triggered mutation B on the same field. Rolling back to A’s snapshot discards B’s newer optimistic value — an out-of-order response overwriting fresher state.

Fix: Before restoring, check the snapshot for that id still exists and is still the latest for that field; if a newer mutation supersedes it, reverse only A’s delta rather than restoring its snapshot, or discard the stale rollback entirely. Track a per-field latest-mutation id to detect supersession.

The UI reverts but the error is invisible to the user

Root cause: The rollback restores state but only logs to the console, so the user sees the value silently snap back with no explanation and assumes a glitch.

Fix: Pair every rollback with a visible, accessible affordance — an inline message with role="status" or a toast — describing that the action failed and was reverted, ideally with a retry. Silent reversion erodes trust more than a brief error does.


Not Every Failure Deserves a Rollback Three response classes. A network or timeout failure is retryable, so keep the optimistic value and retry once before rolling back. A validation error means the value was never acceptable, so roll back and surface the message. A conflict means someone else changed the record, so replace the local value with the server's rather than restoring the snapshot. Three failure classes, three different responses NETWORK timeout, offline, 5xx retry once, then roll back keep the optimistic value while the retry is in flight VALIDATION 422, rejected input roll back immediately the value was never going to be accepted — say why CONFLICT 409, version mismatch take the server's value restoring the snapshot would show a value already stale

Frequently Asked Questions

Why not just refetch from the server after an error instead of restoring a snapshot?

Refetching adds a round-trip of latency and a second flash of stale-then-correct UI, and it can clobber other optimistic mutations still in flight. A local snapshot restores instantly and precisely to the state before the failed mutation, so the rollback is immediate and does not touch unrelated pending changes.

How do I roll back one failed mutation without undoing later successful ones?

Do not restore a whole-store snapshot blindly. Tag each mutation with an id, and on failure reverse that mutation's specific delta or rebase — reapply the remaining in-flight mutations on top of the restored base. This isolates the rollback so a stale snapshot does not erase changes that landed after it was taken.

Does rolling back require re-hydrating the island?

No. The rollback is a local state write in the already-hydrated island; it restores the previous value and lets the framework re-render that island only. Nothing about the server-client boundary or other islands is re-hydrated, which is the whole point of optimistic updates without full hydration.

← Back to Optimistic Updates Without Full Hydration