React Race Conditions: The Slowest Response Wins the Screen
You'll learn why out-of-order async responses show stale data in React, why debouncing and a faster network don't fix it, and which tool to reach for: an ignore flag, AbortController, a request token, useActionState's queue, or a data library.
TL;DR: A race condition in React isn't about speed. It's about priority. When two async requests are in flight, the network gives you no ordering guarantee, so a slow older response can land after a fast newer one and overwrite the screen with stale data. The rule your UI needs is "latest wins," and you enforce it by ignoring stale results (cleanup flag), cancelling them (AbortController), or gating them behind a request token. Debouncing and a faster backend hide the bug. They don't remove it.
You switch from Alice's profile to Bob's. For a split second Bob's page renders. Then it flickers back to Alice. You blink, refresh, and it's fine. QA can't reproduce it. The diff looks correct. It passes review. Two weeks later a support ticket says a user "saw someone else's data," and now it's a security conversation instead of a rendering bug.
That's a race condition. It's one of the few bugs that is invisible in a static code review, absent in fast local dev, and dependent entirely on network timing you don't control. The code that causes it is the code everyone wrote in 2019 and half of us still ship:
function Profile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, [userId]);
if (!user) return <Skeleton />;
return <h1>{user.name}</h1>;
}The dependency array is right. The cleanup is missing. And that missing cleanup is the whole bug.
A Race Condition Is About Priority, Not Speed
The instinct when you hear "race condition" is "something is too slow." So people reach for debouncing, or they blame the backend, or they add a spinner and move on. All three miss what's actually happening.
Here's the sequence when a user switches from userId=1 to userId=2 quickly:
t=0ms request A starts (userId=1)
t=10ms userId changes → request B starts (userId=2)
t=60ms response B arrives → setUser(Bob) ✅ screen shows Bob
t=200ms response A arrives → setUser(Alice) ❌ screen shows AliceBoth .then callbacks are alive. Both call setUser. Whichever resolves last wins, and the network decides who resolves last based on payload size, server load, routing, and cache state. None of that correlates with which request you actually want.
JavaScript is single-threaded but non-blocking. When you fire a fetch, you register a callback that runs whenever the response shows up. Two in-flight requests have no inherent ordering. The assumption that responses come back in the order you sent them is the root cause of every UI race condition.
The policy you actually want is not "first come, first served." It's latest wins. The most recently initiated request is the only one allowed to touch the screen. Everything else should be ignored or cancelled the moment it's no longer the newest.
The Mental Models That Get This Wrong
"Debouncing fixes it." It doesn't. Debouncing waits 300ms after the last keystroke before firing a request. That reduces how many requests you send. It does nothing about ordering. Two debounced requests can still resolve out of order. If someone types "re", pauses 300ms (request fires), then types "act" and pauses again (second request fires), you have two in-flight requests and the same race. Debouncing is a performance optimization. It is not a correctness tool.
"A faster API fixes it." A faster API makes the window smaller. It does not close it. The whole point is that response times vary per request. A cached response for the old query can beat an uncached response for the new one every time. You cannot out-optimize a timing assumption. You have to remove the assumption.
"async/await serializes the requests." No. await pauses the function it's in. It does not pause the event loop. Two event handlers that each await fetch() run concurrently. Writing it with async/await instead of .then changes the syntax, not the concurrency.
// This is exactly as racy as the .then version.
useEffect(() => {
async function load() {
const res = await fetch(`/api/users/${userId}`);
setUser(await res.json()); // still runs whenever it resolves
}
load();
}, [userId]);Once you stop thinking "how do I make this faster" and start thinking "which request owns the right to update the screen," the fixes get obvious.
Fix 1: The Ignore Flag (React's Own Recommendation)
The useEffect cleanup function runs before the next effect and on unmount. That's your cancellation point. Set a flag, flip it in cleanup, check it before writing state.
function Profile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
if (!user) return <Skeleton />;
return <h1>{user.name}</h1>;
}When userId changes from 1 to 2, React runs the cleanup for the first effect (setting its ignore to true) before running the second effect. Request A's .then still fires, but its ignore is now true, so the stale setUser(Alice) is skipped. Each effect run closes over its own ignore variable, which is exactly what makes this work.
This is the pattern the React docs reach for first, and for good reason: it's small, it has no browser support caveats, and it's obviously correct once you see it. Its one limitation is honest. The request still completes. You're paying for the bytes and the server work. You're just refusing to use the result.
Fix 2: AbortController (Cancel the Request, Not Just the Result)
When the fetch layer supports cancellation, aligning the request lifecycle with the component lifecycle is cleaner. AbortController actually kills the in-flight request.
function Profile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, [userId]);
if (!user) return <Skeleton />;
return <h1>{user.name}</h1>;
}The one part people forget: aborting a fetch makes the promise reject with an AbortError. If you don't catch and ignore it, cancellation shows up in your error state or your logs as noise. That if (err.name !== "AbortError") line is not optional.
So which do you pick?
| Ignore flag | AbortController | |
|---|---|---|
| Prevents stale state write | Yes | Yes |
| Cancels the HTTP request | No | Yes |
| Saves bandwidth / server work | No | Yes |
| Works with non-fetch async | Yes | Only if the API accepts a signal |
| Browser support caveat | None | None in practice (legacy browsers only) |
Reach for AbortController when you're doing real network requests and want to free up connections. The abort signal also chains: pass one signal to three fetch calls in the same effect and a single controller.abort() cancels all of them.
Fix 3: The Request Token (When You Can't Abort)
Not every async source accepts an abort signal. A geolocation lookup, an IndexedDB read, a third-party SDK method, a Web Worker round trip, an old axios version. You can't cancel them, but you can still enforce "latest wins" with a monotonically increasing ID.
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
const latestRequestId = useRef(0);
useEffect(() => {
const requestId = ++latestRequestId.current;
searchViaUncancellableSdk(query).then((data) => {
// Only the newest request is allowed to write.
if (requestId === latestRequestId.current) {
setResults(data);
}
});
}, [query]);
return <ResultList results={results} />;
}Every effect run bumps the counter and captures its own requestId. When a response comes back, it checks whether it's still the latest. If a newer request has already started, the counter moved on, the IDs don't match, and the stale write is dropped. Same "latest wins" contract as the ignore flag, but it survives across an async source that has no cleanup hook of its own.
The ignore flag and the request token are the same idea at different granularities. The flag answers "is this effect run still active?" The token answers "is this the newest request?" For a single input driving a single effect they're interchangeable. The token matters when several requests can be in flight and you need to know which one is current, not just whether the current one was torn down.
The Same Bug Wearing Different Clothes
Once you know the shape, you start seeing it everywhere that isn't a search box.
Tab and filter switching. User clicks Tab A, then Tab B before A loads. B renders. A's slow response lands and overwrites B. The user is looking at Tab B's header with Tab A's content underneath it. Same fix: the effect keyed on the active tab needs a cleanup guard.
Pagination. User clicks page 2, then page 3. Page 3 arrives first and renders. Page 2 arrives second and replaces it. The URL says page 3. The rows are from page 2. This one is especially nasty because the pagination controls look correct while the data lies.
Concurrent mutations. This is the one that costs data, not just pixels.
// Two edits, last server response wins, and it might be the wrong one.
async function saveField(field: string, value: string) {
const updated = await api.patch(`/profile`, { [field]: value });
setProfile(updated); // whole-object response clobbers the other field
}User edits the name, then the email, fast. The name request resolves last, returns the full profile object as it was before the email edit, and setProfile overwrites the email change on screen. The server might even be consistent. The UI isn't, because you let a stale response paint the whole object. Fix it by merging only the field you changed, or by tracking which mutation is authoritative, or by moving to sequential submission (more on that below).
Lost updates in useState. A subtle cousin. Not out-of-order network, but out-of-order reads of the same render's state.
// ❌ both closures read the same `count`, one increment is lost
onClick={() => {
setCount(count + 1);
setCount(count + 1); // both see the same count, result is +1 not +2
}}
// ✅ functional updates queue correctly
onClick={() => {
setCount((c) => c + 1);
setCount((c) => c + 1); // +2
}}Any time the next state depends on the previous, use the functional updater. It's the state-level version of "don't trust a value you captured earlier."
What React 19 Actually Fixes (And What It Doesn't)
React 19's Actions get pitched as the answer to async UI, so it's worth being precise about which races they handle.
useActionState queues. Dispatches run sequentially. Each call to your action receives the result of the previous one. That kills a whole class of mutation races, because a later submission can't start until the earlier one finishes. This is genuinely the right default for form mutations.
const [state, submitAction, isPending] = useActionState(
async (prev, formData) => {
const result = await saveToServer(formData);
return result; // next dispatch waits for this to resolve
},
initialState,
);The tradeoff is honest and named in the docs: sequential means a backlog. Click-spam serializes into a queue of network calls, and the UI can feel laggy while it drains. For rapid-fire interactions you're expected to add useOptimistic, cancellation, or a different model.
useTransition does not guarantee order. This surprises people. Async work inside a transition can still resolve out of order, and React says so directly in its own docs:
When clicking multiple times, it's possible for previous requests to finish after later requests. When this happens, React currently has no way to know the intended order... Actions within a Transition do not guarantee execution order.
Transitions deprioritize stale renders. They do not cancel network requests, and they do not sequence your async calls. If you're firing requests inside startTransition and expecting the last click to win, you still need your own abort or token logic. useDeferredValue has the same limitation: it's about render priority, not request ordering.
useOptimistic handles rollback, not ordering. It shows the intended final state while the action is pending and reverts to the source of truth on failure. Great for perceived speed. It does not decide which of two concurrent responses is authoritative. That's still on you or your data layer.
The short version: React 19 solved mutation ordering for the sequential form case and gave you rollback for optimistic UI. It did not solve out-of-order responses for parallel reads. That's still an application problem.
Debounce and Cancel, Together
Debouncing and cancellation solve different problems, and the search box wants both. Debounce to cut the request count. Cancel to enforce latest-wins on the ones that do fire.
function LiveSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then(setResults)
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultList results={results} />
</>
);
}The cleanup clears the pending timer (so a keystroke that arrives within 300ms cancels the not-yet-fired request) and aborts any request that already went out. Debounce alone would still let two spaced-out requests race. Cancel alone would fire a request per keystroke. Together they're both cheap and correct.
The Honest Recommendation: Stop Hand-Rolling This
Every fix above is correct. You also shouldn't be writing them by hand in fifty components. This class of bug (out-of-order responses, deduplication, cancellation on key change, stale-while-revalidate) is exactly what TanStack Query and SWR exist to solve. They cancel in-flight queries on refetch, dedupe identical requests, and enforce latest-wins as a default rather than something you remember to add.
function Profile({ userId }: { userId: string }) {
const { data: user, isLoading } = useQuery({
queryKey: ["user", userId],
queryFn: ({ signal }) => fetchUser(userId, signal),
});
if (isLoading) return <Skeleton />;
return <h1>{user.name}</h1>;
}The queryKey is the entire trick. When userId changes, the key changes, the old query is abandoned, and only the response tied to the current key can populate data. The signal gets wired straight into fetch for real cancellation. You get race safety by default instead of by discipline.
If you're on Next.js or another framework with server data fetching, the cleaner move is often to fetch in a Server Component and skip the client-side dance entirely. No effect, no in-flight overlap, no cleanup to forget. The request runs once per render on the server, scoped to that request.
Rolling your own is fine for a one-off. For an app, "we'll just be careful in every component" is how the profile flicker ships to production.
Testing a Bug That Depends on Timing
The reason race conditions survive to production is that they don't reproduce on a fast local network. So force them. A good regression test returns responses out of order on purpose and asserts the newest one wins.
// Return the first (stale) request slowly, the second one fast.
const responses = [
{ delay: 200, body: aliceProfile },
{ delay: 20, body: bobProfile },
];
let call = 0;
server.use(
http.get("/api/users/:id", async () => {
const { delay, body } = responses[call++];
await sleep(delay);
return HttpResponse.json(body);
}),
);
// Switch userId 1 → 2, wait for everything to settle,
// assert the screen shows Bob, and that Alice never flashed in.If the UI renders Bob and stays on Bob after the slow Alice response lands, your fix is real. If Alice flashes in, you're still racing. Also lean on Strict Mode in development. Its mount, unmount, remount cycle surfaces missing cleanup by running your effect twice, which is precisely the condition that exposes an unguarded fetch.
The general rule: any component that sets state from async work needs a test that delays the first response longer than the second. If you never test out-of-order, you're trusting that production networks behave better than they do.
The Decision Tree
1. Are you mutating (POST/PATCH), and should submissions run in order?
→ useActionState (queues sequentially) or a data-layer mutation.
2. Are you reading data keyed on a changing value (id, query, page, tab)?
→ You have a potential race. Continue.
3. Can the async source be cancelled (fetch, axios with signal)?
→ AbortController in the effect cleanup.
4. Can't cancel it (SDK, IndexedDB, geolocation, worker)?
→ Ignore flag or request-token guard in cleanup.
5. Firing requests inside startTransition / useDeferredValue?
→ Those don't order responses. Add abort or token anyway.
6. Doing this in more than a couple of components?
→ TanStack Query / SWR, or fetch in a Server Component.Quick Reference
| Scenario | Why it races | Fix |
|---|---|---|
| Profile / detail by id | Old id's response lands last | Ignore flag or AbortController on id change |
| Search-as-you-type | Slow early query beats fast late one | Debounce + AbortController |
| Tab / filter switch | Slow tab overwrites active tab | Cleanup guard keyed on active tab |
| Pagination | Earlier page arrives after later page | Guard keyed on page number |
| Concurrent field edits | Whole-object response clobbers other field | Merge changed field, or sequence with useActionState |
setCount(count + 1) twice | Both read same render's state | Functional updater setCount(c => c + 1) |
startTransition async | Transitions don't order responses | Abort or request token inside |
| Uncancellable async (SDK, IDB) | No signal to abort | Request-token guard |
| Many components fetching | Same race repeated everywhere | TanStack Query / SWR / Server Component |
What Changed in How You Should Think About This
The trap is framing race conditions as a performance problem. They're not. A slow network doesn't cause the bug. A missing priority policy does. The question is never "how do I make this faster." It's "which request is allowed to write to the screen right now, and how does every other request find out it lost."
Every fix in this post answers that same question with a different mechanism. The ignore flag says "this effect run is no longer active." AbortController says "this request is dead, stop it at the source." The request token says "you're not the newest, drop your result." useActionState says "wait your turn." A data library says "the key changed, you don't exist anymore." Pick based on whether you can cancel and how many places need it.
The profile that flickers back to Alice isn't mysterious. It's an old response doing exactly what you told it to: call setUser when it finishes. You just never told it that finishing late means it lost. Say that once, in cleanup, and the flicker is gone.