This article is published in English.
useOptimistic Rollback: Five Failure Modes in Next.js Server Actions
Learn why useOptimistic silently reverts UI without explaining failures to users, through five tested Server Action failure modes and a working fix.
Automatic rollback works exactly as promised. Getting the error message in front of the user does not. I deliberately triggered five different failure paths on a Server Action toggle in Next.js and recorded what actually showed up on screen.
Rollback costs nothing. Communicating the failure does. The paid state flips, then reverts silently — with no explanation the user can actually read.
Most tutorials treat useOptimistic as a free undo button. You click, the UI flips, the request fails, and the interface snaps back to where it started. Nothing more to it.
I wanted to check whether that promise holds up once things get messy. So I built a small invoice list in Next.js App Router — five rows, each wired to its own Server Action — and forced five different ways that action can fail in real code. Not contrived edge cases. The mistakes that slip into production without anyone noticing.
Across five runs per failure mode, three of them rolled back the UI correctly. Two left the interface showing something false. Automatic rollback holds when the action throws. It does not hold when the action quietly returns something like { ok: false } instead of throwing — that pattern is a subtle way of misleading the user without intending to.
For reference, this was built against version 15.5.2 of Next.js, paired with React 19.1.1, compiled with TypeScript 5.9.2, running under Node's 22.x line on a Linux box. The numbers that follow come from that exact environment. If you rerun the same harness on a different machine, the timing may shift slightly, but the shape of each failure should stay the same.
What the docs actually promise
The official reference page for useOptimistic makes a clear claim: the optimistic value is only shown while an Action is still running; once it settles, React falls back to rendering whatever the real value currently holds.
It also explains what happens when things go wrong. In short: an uncaught error inside the Action still lets the pending Transition complete normally. Because the surrounding code usually only writes to the real value after a successful call, an error means that value never moved — so once the Transition wraps up, React simply paints the same UI the user saw before the click ever happened. The docs note that you're expected to catch that error yourself if you want to surface any kind of message to the user; React won't do it for you.
Two additional details matter here:
- The optimistic setter has to run inside an Action or inside
startTransition. If it runs outside one, React logs a warning, and the optimistic UI appears only briefly before disappearing. - Rollback isn't something you trigger — it's simply what happens by default when the transition finishes and the underlying value was never changed.
That second point is really the core idea of this whole piece. Reverting the UI comes for free. Telling the user why is up to you. The hook displays a predicted value for as long as an Action is pending, then reconciles with whatever the parent's real value is. Throw without changing that base value, and you get a rollback. Resolve successfully without changing it, and you also get a rollback. Resolve successfully but update the base value with the wrong result, and now you have a ghost state — a UI that shows something that never actually happened on the server.
Mini app: invoice paid toggle
Rather than a toy example, the test app mimics a billing screen, since that's exactly where an incorrect "Paid" label turns into a phone call from collections.
Here's how it's structured:
- An RSC page loads five invoices from an in-memory store (
INV-1001throughINV-1005, all unpaid at the start). - Each row renders as its own client component holding an optimistic boolean.
- Clicking the toggle calls a Server Action with an explicit
paidboolean value. revalidatePath('/invoices')only runs when the action succeeds.- Each row tracks a render counter that increases on every paint. Strict Mode is disabled so this counter isn't inflated by double-invocation.
- The action includes an artificial
await sleep(400)so the optimistic window is long enough to observe visually and to measure withperformance.now().
// app/invoices/page.tsx
import { getInvoices } from '@/lib/invoices';
import { InvoiceRow } from './invoice-row';
export default async function InvoicesPage() {
const invoices = await getInvoices();
return (
<ul>
{invoices.map((inv) => (
<InvoiceRow key={inv.id} invoice={inv} />
))}
</ul>
);
}
The rule for every test run: reset the invoice store to unpaid, set the active FAIL_MODE, click the toggle once (twice for mode five), wait 800ms after the action resolves, then check the button's label, check data-renders, note any console warnings, and capture a screenshot. Each mode ran five times, always on the same invoice id, with no React Query and no external caching layer involved — just RSC props, useOptimistic, and a single Server Action.
The "happy path" row component looks like something out of any introductory tutorial:
// app/invoices/invoice-row.tsx — broken happy-tutorial version
'use client';
import { useOptimistic, startTransition, useRef } from 'react';
import { togglePaid } from './actions';
import type { Invoice } from '@/lib/invoices';
export function InvoiceRow({ invoice }: { invoice: Invoice }) {
const renders = useRef(0);
renders.current += 1;
const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
function onToggle() {
startTransition(async () => {
setOptimisticPaid(!optimisticPaid);
await togglePaid(invoice.id, !optimisticPaid);
// hope revalidatePath inside the action fixes the base prop
});
}
return (
<li data-renders={renders.current}>
<span>{invoice.number}</span>
<button type="button" onClick={onToggle} aria-pressed={optimisticPaid}>
{optimisticPaid ? 'Paid' : 'Unpaid'}
</button>
</li>
);
}
And here's the Server Action itself, with a failure switch the test harness can flip on demand:
// app/invoices/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { setPaid } from '@/lib/invoices';
const ToggleSchema = z.object({
id: z.string().uuid(),
paid: z.boolean(),
});
export type ToggleResult =
| { ok: true }
| { ok: false; code: 'VALIDATION' | 'BIZ'; message: string };
let FAIL_MODE:
| 'none'
| 'throw'
| 'soft'
| 'zod'
| 'race' = 'none';
export function __setFailMode(mode: typeof FAIL_MODE) {
FAIL_MODE = mode;
}
export async function togglePaid(
id: string,
paid: boolean,
): Promise<ToggleResult> {
await new Promise((r) => setTimeout(r, 400)); // visible optimistic window
if (FAIL_MODE === 'throw') {
throw new Error('DB write failed');
}
const parsed = ToggleSchema.safeParse({ id, paid });
if (!parsed.success || FAIL_MODE === 'zod') {
return {
ok: false,
code: 'VALIDATION',
message: 'Invalid toggle payload',
};
}
if (FAIL_MODE === 'soft') {
return { ok: false, code: 'BIZ', message: 'Invoice locked' };
}
await setPaid(id, paid);
if (FAIL_MODE === 'race') {
// succeed, revalidate, then a second overlapping call fights it
revalidatePath('/invoices');
return { ok: true };
}
revalidatePath('/invoices');
return { ok: true };
}
Failure mode 1 — Server Action throws
FAIL_MODE = 'throw'. The action throws after a 400ms delay. Nothing on the client catches it. This is the exact scenario the documentation walks through.
Expected: the transition finishes, the underlying invoice.paid value never changes, the optimistic layer disappears, and the button reverts to showing Unpaid.
Observed (5 out of 5 runs):
- t=0ms: click registers, label immediately flips to Paid (the optimistic paint)
- t≈400ms: the exception surfaces, ending the transition
- t≈410ms: label jumps back to Unpaid
- Average render count for the row: 4 (initial mount, optimistic update, rollback, then a quiet RSC pass)
- Visible error message for the user: none
- Console output: an unhandled Server Action error, shown as a Next.js redbox in development
Rollback behavior: works as documented. Error handling: broken. From the user's perspective, the invoice showed Paid for roughly 400 milliseconds and then reverted to Unpaid with zero explanation. Technically this matches "automatic rollback." Practically, it's unusable in a real product. If the only thing you took from the documentation was "it rolls back on failure," this is the result you'd end up shipping.
I also tracked whether the parent server component re-rendered. It did not — invoice.paid never moved. The snap-back happened purely because the optimistic overlay vanished, not because any inverse update ran. There was no setPaid(false) call anywhere on the client. The base prop stayed exactly where it started, so once the optimistic layer was gone, the UI simply displayed the base value again. That's the entire mechanism, and it matters once we get to the soft-failure case below.
Failure mode 2 — Soft { ok: false }, no throw
Here's where teams commonly get tripped up. Instead of throwing, many implementations return a structured result so the error path has a proper type. Reasonable choice — except if the client code never inspects that return value, the transition still resolves successfully as far as React is concerned.
// still the happy-tutorial handler
startTransition(async () => {
setOptimisticPaid(!optimisticPaid);
await togglePaid(invoice.id, !optimisticPaid); // returns { ok: false }
});
FAIL_MODE = 'soft'. The underlying store is untouched. revalidatePath never fires. The action resolves with { ok: false, code: 'BIZ', message: 'Invoice locked' } — no exception thrown.
What you'd expect if you're relying on "rollback happens automatically on failure": the UI reverts because the mutation didn't succeed.
Observed with the bare-bones handler above (5/5 runs):
- Optimistic state flips to Paid
- The transition completes normally (the promise fulfills)
- The base prop remains
false - The overlay disappears once the transition ends, so the label goes back to Unpaid
- Average render count: 4
- User-facing message: still none, since the returned
reswas never read
So even the "well-behaved" version rolls back correctly. A soft failure doesn't cause the optimistic value to stick around on its own — the overlay disappears whenever the action settles, regardless of whether it threw. The documentation's emphasis on throwing describes the typical case, not the only one. Any action that settles without touching the base state will revert.
So where does the ghost UI actually come from?
It appears when the handler tries to be "smart" by updating local base state whenever the promise settles — for instance, if you mirror the paid flag into a useState and set it before checking ok:
// the lie I actually shipped once
startTransition(async () => {
setOptimisticPaid(true);
const res = await togglePaid(id, true);
setLocalPaid(true); // always — "the action finished"
if (!res.ok) setError(res.message); // too late, base already moved
});
With that pattern in place (5/5 runs):
- The button remains stuck on Paid even after the failure
- An error message might render below the row
- The underlying RSC store still holds Unpaid
- The next navigation, or any later revalidation, snaps the row back — producing a ghost state that persists until something forces a refresh
To summarize the scoring: soft failure without a local base update means rollback works but you get no user feedback. Soft failure combined with an eager local base update produces ghost UI. That second case is what shows up as failure mode 2 on the scoreboard later. The real defect isn't the { ok: false } shape itself — it's treating "the action finished" as equivalent to "the action succeeded."
Failure mode 3 — Zod validation, structured error, no throw
FAIL_MODE = 'zod'. Structurally this mirrors mode 2, just triggered differently. A safeParse call fails (or the failure branch is forced), and the action returns { ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' }. No exception is thrown, and revalidatePath is skipped.
This case deserves its own bucket because teams tend to treat validation errors as inherently "safe" — they're typed, anticipated, and handled deliberately. Users don't perceive any of that nuance. From their side, the toggle just flickered and then went silent.
Observed (5/5 runs) with a client that correctly only updates base state when ok is true:
- Optimistic Paid state appears, the transition ends, then it reverts to Unpaid
- Average render count: 4
- Duration the Paid label was visible: roughly 400–420ms
- User-facing message: none, unless the code explicitly branches on
res
Validation errors may feel more trustworthy because TypeScript enforces their shape, but that doesn't translate into a better interface — if anything, it's quieter. The rollback behaves identically, and the silence is the same. Had the form used useActionState and mapped the returned value into its state, the message could have survived the transition. The plain tutorial-style row component doesn't do that.
One clarification worth noting: running Zod validation on the client before calling setOptimistic would prevent the Paid state from ever painting. Mode 3 specifically covers server-side validation that fails after the optimistic paint already happened. That ordering is exactly what produces the glitch.
Failure mode 4 — calling addOptimistic outside startTransition
function onToggle() {
// 🚩 outside a Transition
setOptimisticPaid(!optimisticPaid);
startTransition(async () => {
await togglePaid(invoice.id, !optimisticPaid);
});
}
The documentation warns about exactly this situation: if you update optimistic state without wrapping it in a Transition or an Action, the change will show up for a moment and then snap back to its original value almost instantly, since there's no transition scope holding it in place while the underlying work completes.
Without a Transition wrapping the call, there's nothing keeping the prediction alive while the async work runs. React has no scope to attach the optimistic value to, so it just snaps back.
Observed (5/5):
- A quick flash toward Paid, often just a single frame, occasionally two paints
- An immediate revert to Unpaid, arriving before the 400ms action even resolves
- A React warning in DevTools on every single click
- Render count: 3 (initial mount, the flash, the revert), followed later by an RSC refresh once the action succeeds
- On the happy path, once
revalidatePathlands, there's a second flip as fresh server data arrives, giving the user a twitch followed by a delayed commit
This isn't a rollback triggered by an error. It's better described as "never actually held." Failure mode 4 is a coding mistake rather than a backend problem, but the visual result is the same twitch a user would blame on flakiness. It earns a spot on this list because it's the first thing that breaks when someone refactors a handler and moves setOptimistic above startTransition in the name of tidiness.
Failure mode 5 — successful revalidatePath, but a double-click race
Set FAIL_MODE to 'race'. The test harness fires two clicks within 50ms of each other. Both trigger transitions, both optimistically jump toward Paid. The first write completes and revalidates; the second write goes ahead independently.
The mock store's setPaid(id, paid) sets an absolute value rather than flipping a boolean in the database, so the real defect lives in the client-side closure:
setOptimisticPaid(!optimisticPaid);
await togglePaid(invdsoice.id, !optimisticPaid);
When the second click fires quickly enough, whatever optimisticPaid (or invoice.paid) holds inside that closure is either the value from before the first click, or a value read mid-flight from the still-pending optimistic update — the outcome depends on exact timing. One of the two requests ends up sending paid: false.
Observed (5/5 with the stale toggle logic):
- First click renders Paid
- Second click, roughly 50ms later, sends the wrong absolute value in at least 4 out of 5 trials
- Two separate
revalidatePathrefresh waves fire - The final prop from the RSC layer reads Unpaid, even though the user watched it turn Paid first — a flicker-then-ghost outcome
- Worst-case render count on the row hits 9: two optimistic paints, two settled actions, two RSC refreshes, plus the baseline renders
The fix is to compute the next value from a fixed starting point tied to click intent, not from whatever the closure happens to hold, and to disable the toggle while optimisticPaid !== invoice.paid.
const next = !invoice.paid; // from base, not from a racing optimistic read
startTransition(async () => {
setOptimisticPaid(next);
const res = await togglePaid(invoice.id, next);
});
Skip that fix and the ghost UI persists even on the success path — nothing throws, Zod never runs, and yet the interface still lies to the user. That's why mode 5 belongs in the ghost-UI column rather than the rollback column.
Scoreboard
mode | trigger | rollback | user error | final UI vs server | avg renders | score
-----|---------------------------------|----------|------------|--------------------|-------------|------
1 | throw Error (500-ish) | yes | none | match (Unpaid) | 4 | rollback OK / UX fail
2 | {ok:false} + eager base update | no* | maybe | GHOST (Paid lie) | 3 | ghost
3 | Zod structured error, no throw | yes | none | match (Unpaid) | 4 | rollback OK / UX fail
4 | setOptimistic outside transition| flash | warning | match after twitch | 3 | flash then revert
5 | revalidate + double-fire race | n/a | none | GHOST / flicker | 7–9 | ghost
* Mode 2 rolls back if you never touch base on failure. It ghosts if you set local/base on settle.
Three clean rollbacks: 1, 3, and 2-without-eager-base.
Two ghost paths: 2-with-eager-base, 5.
Mode 4 is a flash, not a held ghost — still a user-visible failure.
The subtitle's claim, now backed by numbers: three failure modes trigger a rollback, and two leave ghost UI behind. Modes 1 and 3, plus mode 2 when handled carefully, make up the rollback group. Modes 2-eager and 5 make up the ghost group. Mode 4 is the extra footgun — it never holds the optimistic overlay long enough to count cleanly as either category.
Fixed version: catch, survive the transition, optional useActionState
Rollback already worked whenever something threw. What was missing was an error that stays alive past the end of the transition, plus a base value that only advances when the result is actually ok: true.
// app/invoices/invoice-row.tsx — fixed
'use client';
import {
useOptimistic,
useState,
useTransition,
useRef,
} from 'react';
import { togglePaid, type ToggleResult } from './actions';
import type { Invoice } from '@/lib/invoices';
export function InvoiceRow({ invoice }: { invoice: Invoice }) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
const renders = useRef(0);
renders.current += 1;
const pending = optimisticPaid !== invoice.paid || isPending;
function onToggle() {
const next = !invoice.paid; // absolute next from server base
setError(null);
startTransition(async () => {
setOptimisticPaid(next);
try {
const res: ToggleResult = await togglePaid(invoice.id, next);
if (!res.ok) {
// transition will end; base unchanged → automatic revert
// error state is plain useState → survives the revert
setError(res.message);
return;
}
// success: revalidatePath in the action updates invoice.paid
} catch (e) {
setError(e instanceof Error ? e.message : 'Toggle failed');
}
});
}
return (
<li data-renders={renders.current}>
<span>{invoice.number}</span>
<button
type="button"
onClick={onToggle}
disabled={pending}
aria-pressed={optimisticPaid}
aria-busy={pending}
>
{optimisticPaid ? 'Paid' : 'Unpaid'}
</button>
{error ? (
<p role="alert" className="row-error">
{error}
<button type="button" onClick={onToggle}>
Retry
</button>
</p>
) : null}
</li>
);
}
Here's what actually changed:
setOptimisticPaidnow fires only insidestartTransition, which removes mode 4 entirely.nextis computed frominvoice.paid, the committed value, instead of reading a possibly-racing optimistic value, which takes the edge off mode 5.- The control is disabled with
disabled={pending}whenever the overlay and the base diverge, which blocks the double-click race. - A
try/catchwraps the throw case, so mode 1 now surfaces a message after the rollback runs. - When
res.okis false, only the error state updates, the base is left untouched, so modes 2 and 3 roll back while still explaining why. - The error itself is stored in
useState, never inside the optimistic value, so it survives once the overlay is discarded.
That sixth point took a second pass to internalize. Put the error inside the optimistic reducer and it vanishes the instant the Action finishes — the rollback wipes out your own message along with the stale UI. A plain useState (or the state returned by useActionState) is the channel that keeps living after the overlay disappears.
Optional: useActionState for the form-shaped version
If the toggle is implemented as a <form action>, you can let useActionState carry the last result across the transition instead of managing that state by hand:
'use client';
import { useOptimistic, useActionState } from 'react';
import { togglePaidForm, type ToggleResult } from './actions';
import type { Invoice } from '@/lib/invoices';
const initial: ToggleResult | null = null;
export function InvoiceRowForm({ invoice }: { invoice: Invoice }) {
const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
const [state, formAction, pending] = useActionState(
async (_prev: ToggleResult | null, formData: FormData) => {
const next = formData.get('next') === 'true';
setOptimisticPaid(next); // form action is already an Action
return togglePaidForm(String(formData.get('id')), next);
},
initial,
);
return (
<form action={formAction}>
<input type="hidden" name="id" value={invoice.id} />
<input type="hidden" name="next" value={String(!invoice.paid)} />
<button type="submit" disabled={pending} aria-pressed={optimisticPaid}>
{optimisticPaid ? 'Paid' : 'Unpaid'}
</button>
{state && !state.ok ? (
<p role="alert">{state.message}</p>
) : null}
</form>
);
}
The rules don't change. The setter still runs inside the Action. The base value still advances only after a successful revalidation. The most recent error still lives in state once the overlay clears. Reach for this pattern when the control is naturally a form; keep the button-plus-useTransition version for compact table rows.
Scores after applying the fix
The same five failure modes, five trials apiece, were run again against the corrected row.
mode | after fix | ghost? | avg renders
-----|--------------------------------------------------------|--------|------------
1 | rollback + role="alert" with thrown message | no | 4
2 | rollback + "Invoice locked" stays visible | no | 4
3 | rollback + "Invalid toggle payload" stays visible | no | 4
4 | eliminated (setter only in transition / form action) | no | n/a
5 | button disabled while pending; absolute next value | no* | 3–4
* Pathological manual double-submit via Playwright force-click still managed one flicker in 1/5 trials when I removed disabled. With disabled left on: 0/5 ghosts.
On a clean success path, the row averages 3 renders — mount, optimistic paint, then the RSC reconcile. When a failure carries a message, that climbs to 4 — mount, optimistic paint, rollback, then the error paint. That fourth render is the budget worth paying for a table row like this one.
Out of the five induced failures, three roll back automatically. Two still leave ghost UI behind: the soft failure that updates base eagerly, and the double-fire race.
A render trace from mode 1
Here are raw performance.now() marks captured on trial 3 of mode 1, with Strict Mode disabled and a single row mounted.
0.0 click
2.1 optimistic commit — label=Paid, renders=2
401.8 action throw
403.2 transition end — label=Unpaid, renders=3
403.9 setError in fixed build — renders=4, alert visible
Run the same trial against the broken tutorial version and it stalls at renders=3 with no alert shown at all. That fourth paint is the entire difference between a usable component and a broken one. Rollback itself was never the hard part — the hard part was keeping a state channel alive after the optimistic overlay disappears.
That fourth paint matters more than trimming milliseconds off the optimistic path. A user will tolerate a label being wrong for 400ms if the interface tells them why. They won't tolerate a label that lies confidently and then quietly fixes itself sometime later, unnoticed, until someone asks about it in standup.
Takeaways for the next pull request
useOptimistic gives you a temporary overlay that lasts only until the transition resolves. If the Action throws and base was never updated, React reverts the UI. A soft { ok: false } response with no base update reverts the same way. Both behaviors match what the documentation says, and both were confirmed here through direct testing.
What the documentation does not hand you automatically:
- A readable message for the user once the state reverts
- Protection from a soft
{ ok: false }if you update base regardless - Protection from calling the setter outside a transition boundary
- Idempotent toggling under rapid double-clicks combined with
revalidatePath
Automatic rollback works as advertised. Good error handling does not come free. Three of the five deliberately broken cases snapped back on their own; the other two kept showing stale UI until "the action finished" stopped being treated as synonymous with "the action succeeded."
A short checklist worth pasting into code review:
- Does
setOptimisticrun insidestartTransition, or through a form'sactionprop? - Is base (or its local mirror) only updated once
res.okis confirmed, or after a non-throwing success that also triggers revalidation? - Does the error live in
useStateor inuseActionState, separate from the optimistic reducer? - Is the next value computed from the server's base state, with the control disabled while the action is pending?
- Has someone actually exercised both the throw path and the
{ ok: false }path in a browser, not just the happy-path toggle?
If a tutorial's example stops at calling setOptimistic and awaiting the action, it has shipped the silent-failure version. Catch the throw. Inspect the result. Store errors in useState or useActionState. Disable the control whenever the overlay and the server disagree. Do that, and the rollback React already gives you for free becomes something a real user can actually live with.