Fixing Race Conditions in React 19 useOptimistic

The Promise of Optimistic UI in React 19
For years, building an optimistic UI—where the interface updates instantly before the server confirms the action—was a tedious process of manual state management, complex Redux thunks, or relying on heavy third-party data fetching libraries like React Query or SWR.
React 19 changed the landscape by introducing the native useOptimistic hook, deeply integrated with Server Actions. The premise is elegant: call a server action, optimistically update the state locally, and if the action fails, React automatically reverts the state for you.
When testing locally on a localhost connection with zero latency, useOptimistic feels like magic. But when deployed to production, specifically in complex applications where users fire multiple actions in rapid succession (like checking off multiple tasks in a highly interactive dashboard), this "magic" introduces insidious race conditions.
The Race Condition: Out-of-Order Server Responses
The core vulnerability in optimistic UI patterns is network unpredictability. Unlike your local machine, production networks do not guarantee that HTTP requests will resolve in the exact order they were sent.
Consider a simple "Todo List" application where a user rapidly toggles the status of two items: Task A and Task B.
-
T=0ms: User clicks "Complete" on Task A. The
useOptimistichook instantly marks Task A as complete in the UI. A Server Action (updateTask(A)) is fired. -
T=100ms: User clicks "Complete" on Task B. The UI instantly marks Task B as complete. A second Server Action (
updateTask(B)) is fired.
At this exact moment, the optimistic UI shows both tasks as complete. This is the desired state. However, the race condition occurs during the server response phase.
-
T=300ms: The server processes
updateTask(B)first (perhaps it was routed to an Edge node closer to the user, or the database lock for Task B was resolved faster). The server returns the updated state where Task B is complete, but Task A is still pending. -
T=350ms: React receives the response for Task B and finalizes the optimistic update. It re-renders the UI using the state provided by the server. Because the server thinks Task A is still incomplete, the UI visibly flickers and reverts Task A back to its incomplete state.
-
T=800ms: The server finally finishes processing
updateTask(A)and returns the final state where both are complete. The UI updates again.

This UI flickering destroys user trust. The user thinks their action failed because the UI reverted, leading them to click the button a second time, compounding the race condition and potentially causing database conflicts.
Why useOptimistic Can't Fix This Alone
It's a common misconception that useOptimistic automatically handles request ordering. It does not. The hook is strictly a state-merging utility; it applies a temporary state on top of the "truth" provided by your parent component or data fetcher.
If your server action returns stale data (because an older request resolved after a newer one), useOptimistic will dutifully discard its temporary state and render the stale truth.
To fix this, we have to bypass the happy path and implement manual request cancellation at the network layer.
The Solution: AbortController & Request Cancellation
To guarantee that the UI never reverts to a stale state, we must ensure that when a new mutation is triggered, any pending mutations that could return conflicting state are immediately cancelled.
In modern JavaScript, this is achieved using the AbortController API. However, there is a massive caveat in Next.js and React 19: Server Actions do not support native request cancellation.
Because Server Actions are invoked over an RPC (Remote Procedure Call) layer, the arguments must be serializable. You cannot pass a complex DOM object like an AbortSignal to a Server Action.
If you need strict request cancellation to prevent optimistic race conditions, you must abandon Server Actions for that specific mutation and fall back to standard Route Handlers (API endpoints) using client-side fetch.
1. The Route Handler (API Endpoint)
First, create a standard Next.js Route Handler. This endpoint will process the request.
// app/api/tasks/[id]/route.ts
import { NextResponse } from 'next/server';
export async function PUT(request: Request, { params }: { params: { id: string } }) {
const body = await request.json();
// Perform database update here
// await db.task.update({ where: { id: params.id }, data: body });
return NextResponse.json({ success: true, id: params.id, completed: body.completed });
}
2. The Request Queue Manager
Next, create a client-side wrapper around fetch that maintains a dictionary of active AbortController instances.
// lib/actionQueue.ts
let activeControllers: Record<string, AbortController> = {};
export function createAbortableFetch(actionId: string) {
return async (url: string, options: RequestInit) => {
// Cancel the previous request if it's still running
if (activeControllers[actionId]) {
activeControllers[actionId].abort('Cancelled by newer request');
}
// Create a new controller for this specific request
const controller = new AbortController();
activeControllers[actionId] = controller;
try {
// Pass the signal to the fetch request
const response = await fetch(url, { ...options, signal: controller.signal });
return await response.json();
} catch (error: any) {
if (error.name === 'AbortError') {
console.log(`Action ${actionId} cancelled.`);
return { aborted: true };
}
throw error;
} finally {
// Clean up the controller
if (activeControllers[actionId] === controller) {
delete activeControllers[actionId];
}
}
};
}3. Integrating with useOptimistic
Finally, wire this up in your React component. When the user rapidly clicks multiple tasks, the createAbortableFetch wrapper will instantly cancel the previous in-flight requests. This ensures that only the absolute latest server response dictates the final state.
'use client'
import { useOptimistic } from 'react';
import { createAbortableFetch } from './actionQueue';
const safeFetch = createAbortableFetch('update-task');
export default function TodoList({ initialTasks }) {
const [optimisticTasks, addOptimisticTask] = useOptimistic(
initialTasks,
(state, newTask) => {
return state.map(task => task.id === newTask.id ? newTask : task);
}
);
async function handleToggle(task) {
// 1. Optimistically update the UI instantly
addOptimisticTask({ ...task, completed: !task.completed });
// 2. Fire the safely wrapped fetch request (Not a Server Action)
const response = await safeFetch(`/api/tasks/${task.id}`, {
method: 'PUT',
body: JSON.stringify({ completed: !task.completed })
});
// 3. If aborted, do nothing (the newer request will handle the final state)
if (response?.aborted) return;
// Handle actual errors or finalize state here...
}
return (
<ul>
{optimisticTasks.map(task => (
<li key={task.id} onClick={() => handleToggle(task)}>
{task.title} - {task.completed ? 'Done' : 'Pending'}
</li>
))}
</ul>
);
}Conclusion
React 19's useOptimistic hook dramatically lowers the barrier to entry for building snappy, responsive UIs. However, it does not rewrite the laws of physics. Network latency and out-of-order responses remain a critical threat to data integrity and user experience. By coupling native optimistic updates with strict AbortController cancellation queues, you can build enterprise-grade React applications that are both instantly responsive and rigorously consistent.
