Testing Optimistic Updates and Rollback

An optimistic update shows the result of an action before the server has confirmed it: the item is ticked, the like count rises, the card moves to the new column — instantly. It makes an interface feel fast, and it creates a promise the code must keep: if the server says no, the interface must undo the change and tell the user, without losing anything else that happened in the meantime. Most optimistic updates are tested only on the path where the server says yes, which is the path least likely to go wrong. This guide covers testing all three phases — the instant change, the confirmation, and the rollback — plus concurrent mutations and the final reconciliation, with TanStack Query and MSW. It sits under data fetching and cache testing.

Root Cause Analysis

An optimistic update is three pieces of code that must agree. onMutate snapshots the current cache and writes the optimistic value. onError restores the snapshot. onSettled invalidates the query so the server’s truth replaces whatever the cache holds. Each is easy to write and easy to get subtly wrong, and only the first runs on the happy path.

The characteristic bugs are all on the failure path. onError restores a snapshot taken before an earlier, still-pending mutation, undoing that one too. The snapshot is taken by reference, so the “restored” value is the already-mutated object. The error is swallowed, so the item silently reverts and the user never learns why their change disappeared. None of these shows up if the server always succeeds.

Concurrency adds a further class. A user who toggles two items quickly has two mutations in flight; if the first fails, rolling back must restore only the first item. And an in-flight refetch that lands between the optimistic write and the server’s response can overwrite the optimistic value with stale data, making the change appear to undo itself briefly. Cancelling outgoing queries in onMutate prevents that, and a test that holds a refetch open proves it.

The three phases of an optimistic update onMutate snapshots the cache and applies the optimistic value, then either the server confirms and onSettled refetches to reconcile, or the server rejects and onError restores the snapshot and shows a message before onSettled refetches. onMutate snapshot, apply server confirms keep the change onError restore, tell the user onSettled refetch the truth
The lower branch is where optimistic UI earns or loses the user's trust, and it is the one usually untested.

Reproducible Setup

A to-do list where toggling an item is optimistic, using the standard TanStack Query pattern.

// src/todos/useToggleTodo.ts
export function useToggleTodo() {
  const client = useQueryClient();
  return useMutation({
    mutationFn: ({ id, done }: { id: string; done: boolean }) =>
      fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ done }) }).then((r) => {
        if (!r.ok) throw new Error('toggle failed');
      }),
    onMutate: async ({ id, done }) => {
      await client.cancelQueries({ queryKey: ['todos'] });
      const previous = client.getQueryData<Todo[]>(['todos']);
      client.setQueryData<Todo[]>(['todos'], (old = []) => old.map((t) => (t.id === id ? { ...t, done } : t)));
      return { previous, id };
    },
    onError: (_err, _vars, ctx) => {
      client.setQueryData<Todo[]>(['todos'], (current = []) =>
        current.map((t) => (t.id === ctx?.id ? ctx.previous!.find((p) => p.id === t.id)! : t)));
      toast.error('That change could not be saved.');
    },
    onSettled: () => client.invalidateQueries({ queryKey: ['todos'] }),
  });
}

Restoring only the affected item, rather than the whole snapshot, is what keeps a failure from undoing other in-flight changes — the concurrency test below proves it.

Implementation

Step 1 — Assert the instant change before the server answers. Hold the PATCH open, click, and assert the checkbox is already ticked.

// src/todos/TodoList.test.tsx
test('ticks the item immediately, before the server responds', async () => {
  const gate = deferred<void>();
  server.use(
    http.get('/api/todos', () => HttpResponse.json([{ id: 't1', title: 'Write tests', done: false }])),
    http.patch('/api/todos/t1', async () => { await gate.promise; return new HttpResponse(null, { status: 204 }); }),
  );
  const user = userEvent.setup();
  renderWithQuery(<TodoList />);

  const box = await screen.findByRole('checkbox', { name: 'Write tests' });
  await user.click(box);
  expect(box).toBeChecked();              // optimistic — the PATCH is still pending
  gate.resolve();
});

Step 2 — Confirm the change survives the server’s success and reconciliation. After the server confirms and the list refetches, the item stays ticked.

test('keeps the change once the server confirms', async () => {
  let done = false;
  server.use(
    http.get('/api/todos', () => HttpResponse.json([{ id: 't1', title: 'Write tests', done }])),
    http.patch('/api/todos/t1', async ({ request }) => { done = (await request.json()).done; return new HttpResponse(null, { status: 204 }); }),
  );
  const user = userEvent.setup();
  renderWithQuery(<TodoList />);
  await user.click(await screen.findByRole('checkbox', { name: 'Write tests' }));
  await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Write tests' })).toBeChecked());
});

Step 3 — Roll back on failure and tell the user. The checkbox returns to its previous state and a message explains why.

test('reverts and explains when the server rejects the change', async () => {
  server.use(
    http.get('/api/todos', () => HttpResponse.json([{ id: 't1', title: 'Write tests', done: false }])),
    http.patch('/api/todos/t1', () => new HttpResponse(null, { status: 500 })),
  );
  const user = userEvent.setup();
  renderWithQuery(<><TodoList /><Toaster /></>);
  const box = await screen.findByRole('checkbox', { name: 'Write tests' });

  await user.click(box);
  await waitFor(() => expect(box).not.toBeChecked());
  expect(await screen.findByRole('status')).toHaveTextContent('That change could not be saved.');
});

Step 4 — Test concurrent mutations where only one fails. Rolling back the failed item must leave the successful one alone.

test('a failed toggle does not undo another in-flight toggle', async () => {
  server.use(
    http.get('/api/todos', () => HttpResponse.json([
      { id: 't1', title: 'Write tests', done: false },
      { id: 't2', title: 'Ship it', done: false },
    ])),
    http.patch('/api/todos/t1', async () => { await delay(50); return new HttpResponse(null, { status: 500 }); }),
    http.patch('/api/todos/t2', async () => { await delay(100); return new HttpResponse(null, { status: 204 }); }),
  );
  const user = userEvent.setup();
  renderWithQuery(<TodoList />);
  await user.click(await screen.findByRole('checkbox', { name: 'Write tests' }));
  await user.click(screen.getByRole('checkbox', { name: 'Ship it' }));

  await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Write tests' })).not.toBeChecked());
  expect(screen.getByRole('checkbox', { name: 'Ship it' })).toBeChecked();
});
Whole-snapshot rollback versus per-item rollback Restoring the whole snapshot when the first of two concurrent toggles fails also undoes the second, successful toggle; restoring only the failed item leaves the other change in place. restore whole snapshot t1 fails, t2 succeeds snapshot predates both t2 is wrongly undone restore failed item only t1 fails, t2 succeeds only t1 reverts t2 stays ticked
Only a test with two mutations in flight distinguishes these, and the whole-snapshot version is the textbook default.

Step 5 — Prove outgoing refetches are cancelled. Hold a refetch open, apply the optimistic change, release the stale response, and assert the optimistic value is not overwritten.

Written out, that test holds the list refetch with a deferred response, clicks the checkbox, releases the refetch with the old unticked data, and asserts the checkbox stays ticked. Without the cancellation in onMutate, the stale response lands after the optimistic write and the item briefly unticks — a flicker users notice and report as the interface “not saving”, even though the server eventually agrees.

Step 6 — Assert the final refetch reconciles. If the server returns something different from the optimistic guess — a normalised title, a server timestamp — the settled refetch should replace the optimistic value with the server’s version.

The reconciliation test is also where the optimistic guess is checked against reality. An optimistic update is a prediction of what the server will return, and predictions drift: the server starts trimming whitespace, adding a completion timestamp, reordering the list. Asserting that the settled state shows the server’s version, not the guess, is what stops those small divergences from accumulating into an interface that disagrees with the database until the next page load.

Verification

Confirm the concurrency test guards the right bug by changing onError to restore the whole snapshot. The “Ship it” assertion must fail, which is exactly the regression users would experience as a change undoing itself.

npx vitest run src/todos/TodoList.test.tsx --reporter=verbose
# ✓ ticks the item immediately, before the server responds
# ✓ keeps the change once the server confirms
# ✓ reverts and explains when the server rejects the change
# ✓ a failed toggle does not undo another in-flight toggle

Then confirm the rollback message is not optional: remove the toast from onError. The rejection test must fail on the status assertion, since a silent revert is a defect in its own right.

The minimum set of optimistic-update tests Instant change while pending, change kept on success, revert with a message on failure, and correct behaviour with concurrent mutations together cover the promises an optimistic update makes. instant while pending confirmed kept after refetch rejected revert and explain concurrent only the failure reverts
Four tests, one per promise — and three of the four are about something other than the happy path.

Troubleshooting

Symptom: the optimistic change flickers back and forth. Diagnosis: a refetch in flight when the mutation started lands with stale data. Fix: await client.cancelQueries(...) at the start of onMutate, and add the Step 5 test to keep it there.

Symptom: the rollback restores the wrong value. Diagnosis: the snapshot was the same object that was then mutated in place. Fix: update immutably in setQueryData, so the snapshot remains the previous value.

Symptom: the rejection test passes even without onError. Diagnosis: onSettled refetches and restores the server’s state anyway, so the checkbox reverts regardless. Fix: assert the message as well, and hold the refetch open to check the rollback happens before it.

Symptom: the concurrent test is flaky. Diagnosis: relying on relative delays that the machine may compress. Fix: use deferred responses and release them in a chosen order rather than racing two delay calls.

FAQ

When is optimistic UI the wrong choice?

When failure is common or consequential — payments, irreversible deletions, anything a user would be alarmed to see revert. Optimistic updates suit frequent, low-stakes actions whose failure is rare and easily retried.

Should optimistic updates be tested end to end?

One end-to-end test that the action works is enough. The failure and concurrency cases need precise control over response order and outcome, which the component tier provides and a real server does not.

How does this work with SWR?

SWR’s mutate accepts optimisticData and rollbackOnError, which cover the snapshot and restore steps. The four tests above apply unchanged, as the SWR guide’s final step shows in testing SWR revalidation behaviour.

What about React 19’s useOptimistic?

It handles the local optimistic state and reverts automatically when the action settles. The same tests apply — instant change, kept on success, reverted with a message on failure — with the difference that the rollback logic is React’s, so the concurrency test verifies your action’s error handling rather than a hand-written onError.