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.


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.


← Back to Optimistic Updates Without Full Hydration