All articles
Web DevelopmentOctober 23, 2026 7 min read

The `useOptimistic` Rollback Problem: When Server Actions Fail Silently

React 19's useOptimistic is magic until a server action throws and your UI shows the wrong state. Here's how we debugged a silent rollback bug in production and the pattern we now use.

The `useOptimistic` Rollback Problem: When Server Actions Fail Silently

We shipped a comment thread that felt instant. Users typed, hit send, saw their message appear immediately — classic useOptimistic. Then support started pinging us: messages were "disappearing" minutes later, or sometimes showing up twice. The optimistic UI was lying, and the server was telling the truth too late.

This is the war story and the fix. If you're using React 19's useOptimistic with server actions in the App Router, there are three failure modes that aren't obvious until you have real traffic.

What useOptimistic actually promises

The mental model most teams start with: "optimistic state shows immediately, then gets replaced by the real state when the server responds, and if the action throws, it rolls back." The first two are true. The third is where people get burned.

useOptimistic doesn't know anything about success or failure. It only knows about the lifecycle of the surrounding transition. When the transition ends — successfully or not — the optimistic state is discarded and the component re-renders with whatever the base state is. That base state is whatever you passed as the first argument, usually a prop from a Server Component.

So "rollback" isn't really a rollback. It's just: the optimistic layer evaporates, and you're back to the base state. If your base state hasn't updated (because the mutation failed), the UI silently reverts. If your base state has updated (because the mutation succeeded and you revalidated), the UI shows the new truth. Fine.

The trap: what if the mutation partially succeeded, or the revalidation hasn't landed yet, or the server action throws but you swallowed the error?

A minimal setup that looks correct

'use client';

import { useOptimistic, startTransition } from 'react';
import { sendMessage } from './actions';

type Message = { id: string; body: string; pending?: boolean };

export function Thread({ messages }: { messages: Message[] }) {
  const [optimistic, addOptimistic] = useOptimistic(
    messages,
    (state, next: Message) => [...state, { ...next, pending: true }]
  );

  async function action(formData: FormData) {
    const body = String(formData.get('body') ?? '');
    const temp = { id: crypto.randomUUID(), body };

    startTransition(async () => {
      addOptimistic(temp);
      await sendMessage(temp);
    });
  }

  return (
    <form action={action}>
      {optimistic.map((m) => (
        <div key={m.id} data-pending={m.pending}>{m.body}</div>
      ))}
      <input name="body" />
    </form>
  );
}

This code is in half the tutorials on the internet. It works perfectly on localhost. It will embarrass you in production.

Failure mode one: the server action throws and nobody notices

If sendMessage throws inside startTransition, the transition still resolves. The optimistic update evaporates. The base messages prop hasn't changed. The user sees their message vanish, with no error, no toast, nothing. They retype it. Maybe it goes through the second time. Maybe both go through and you have a duplicate.

In our case the server action was throwing because of a rate limit downstream. The error bubbled up to Next.js, got logged somewhere we weren't watching, and the UI just... reset.

The fix is to actually handle errors. Server actions in React 19 can return structured results instead of throwing:

'use server';

import { z } from 'zod';

const schema = z.object({ id: z.string().uuid(), body: z.string().min(1).max(2000) });

type Result =
  | { ok: true; message: { id: string; body: string } }
  | { ok: false; error: 'rate_limited' | 'invalid' | 'unknown' };

export async function sendMessage(input: unknown): Promise<Result> {
  const parsed = schema.safeParse(input);
  if (!parsed.success) return { ok: false, error: 'invalid' };

  try {
    const message = await db.messages.insert(parsed.data);
    revalidateTag(`thread:${message.threadId}`);
    return { ok: true, message };
  } catch (err) {
    if (isRateLimit(err)) return { ok: false, error: 'rate_limited' };
    reportError(err);
    return { ok: false, error: 'unknown' };
  }
}

Returning results instead of throwing lets the client branch cleanly. Throwing from a server action is fine for truly exceptional cases, but for expected failure modes — validation, rate limits, auth — return them.

Failure mode two: the revalidation race

Even when the action succeeds, you can get a flash of "message gone, message back." Here's why:

  1. User submits. addOptimistic runs. Optimistic message visible.
  2. Server action completes successfully. It calls revalidateTag.
  3. The transition ends. Optimistic state is discarded. React re-renders with the current messages prop.
  4. The revalidated data hasn't propagated to this client component yet because the parent Server Component hasn't re-rendered.
  5. For a brief moment, the UI shows the pre-submit state. Then the parent re-renders and the real message appears.

The flash is short — usually under 100ms in our testing — but on slower connections it's a visible pop. Worse, if the user is already typing their next message, focus and scroll position can jump.

The pattern we settled on: don't let the transition end until you're confident the base state has caught up. In practice, that means awaiting the server response inside the transition and only then letting it resolve:

async function action(formData: FormData) {
  const body = String(formData.get('body') ?? '');
  const temp: Message = { id: crypto.randomUUID(), body };

  startTransition(async () => {
    addOptimistic(temp);
    const result = await sendMessage(temp);

    if (!result.ok) {
      toast.error(errorCopy(result.error));
      // optimistic state will evaporate; base state is unchanged; message disappears
      // which is now correct because we told the user why
    }
    // on success, revalidateTag ran server-side; the parent will re-render
  });
}

This doesn't eliminate the race entirely, but it moves it inside a controlled window where the "pending" styling is still active. The user sees a brief loading state, not a flash of absence.

When you need true continuity, hold the base state yourself

If the flash is unacceptable — a chat UI, a kanban board — the honest fix is to stop treating the server's revalidated prop as the single source of truth for render. Keep a local mirror that merges confirmed server state with in-flight optimistic state:

const [localMessages, setLocalMessages] = useState(messages);

useEffect(() => {
  setLocalMessages((prev) => mergeById(prev, messages));
}, [messages]);

const [optimistic, addOptimistic] = useOptimistic(
  localMessages,
  (state, next: Message) => [...state, { ...next, pending: true }]
);

mergeById keeps any locally-confirmed message that the server hasn't echoed back yet. This is more code and more state, but it kills the flash. We only reach for it when the UI is genuinely conversational.

Failure mode three: optimistic updates outside the matching transition

This one bit us hardest. The React 19 docs are explicit: useOptimistic updates must happen inside the same transition that wraps the async work. If you call addOptimistic outside a transition, or inside a different one than the pending action, the behavior is undefined — and in practice, you get stale UI.

We had a "retry" button that called addOptimistic directly in its onClick, outside startTransition. On a fast network it looked fine. Under load, the optimistic state sometimes stuck around after the retry succeeded, because React couldn't correlate it with any transition lifecycle. Users saw duplicates.

Rule we now enforce in review: every addOptimistic call must be inside a startTransition or a form action, and the async work must be awaited in that same block. If you need to compose, extract a helper that does both.

A checklist we run before merging anything optimistic

  • Does the server action return a structured result instead of throwing for expected failures?
  • Does the client branch on that result and surface errors to the user?
  • Is every addOptimistic call inside a transition or form action?
  • Is the async work awaited inside the same transition?
  • Have you tested with network throttling at Slow 3G to see the flash?
  • Have you tested the failure path by forcing the server action to return an error?
  • If the flash is unacceptable, are you mirroring server state locally with a merge strategy?

Most of this is tedious. All of it has saved us a support ticket at least once.

Where we'd start

If you're adopting useOptimistic on a new feature, write the error path first. Make the server action return { ok: false, error } for every expected failure, wire up a toast, and only then build the happy path. You'll catch the silent-rollback bug on day one instead of after launch.

If you've already shipped and users are reporting "ghost" updates, add logging to the server action's error branch and compare the counts to your UI's error toast counts. If the server is logging failures that the UI never surfaced, you've found the bug. We help teams audit exactly this kind of thing in our web development practice — and if you want more App Router field notes, the blog has a few.

#React 19#Next.js#Server Actions#Performance#TypeScript

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project