Race Conditions in Async React Code
Understand how out-of-order async responses corrupt UI state, and implement AbortController, request IDs, and latest-request-wins patterns to guarantee that only the most recent request's data is ever rendered.
A race condition in a React component happens when two asynchronous operations are in flight at once and they can finish in a different order than they started. The classic trigger is a search box or a tab switcher: the user types 'r', triggering request A for results matching 'r', then quickly types 'react', triggering request B for results matching 'react'. If the server (or the network) happens to answer request A after request B, the component's state update for A runs last and silently overwrites the correct, newer results from B with stale results from A. The UI ends up showing search results for 'r' while the input box still reads 'react' — a bug that is invisible in slow, controlled manual testing and shows up constantly for real users on real networks.
Why this happens specifically in React
Nothing about fetch, promises, or async/await guarantees ordering between two independent requests. Each promise resolves whenever its underlying operation completes, and network latency is never deterministic — a smaller or geographically closer response can easily complete after a larger one that was requested later. JavaScript's single-threaded event loop then runs each .then or post-await continuation in the order the promises actually resolved, not the order the requests were issued.
In a React effect, this means setState calls from two overlapping requests can run in either order. Because state updates from a later setState call always win over an earlier one, the bug is specifically that the response that arrives last — not the response to the most recent request — determines what the user sees. Whichever request is 'stale' when it wins this race is exactly the bug: old data overwriting new data.
This is distinct from the effect-cleanup leaks in the previous module, though the fix uses the same useEffect cleanup mechanism: here we are not just releasing a resource, we are using cleanup to invalidate a specific in-flight request's result before it can be applied.
The core fixes
There are four common, composable techniques for eliminating this class of bug, roughly in order of how much control they give you.
• AbortController — cancel the earlier request outright when a newer one starts, so its promise rejects with an AbortError instead of resolving with data that could be applied. • Request IDs — tag each request with an incrementing number or unique token, and when a response comes back, only apply it if its id still matches the latest request id the component issued. • Cleanup-flag pattern — set a boolean 'ignore' flag in the effect's closure and check it before calling setState in the response handler; cleanup flips the flag so a stale response is a no-op. • Latest-request-wins via query libraries — libraries like TanStack Query, SWR, or RTK Query implement request deduplication, cancellation, and staleness tracking internally, so components that use them get race-condition safety without hand-rolled bookkeeping.
Latest-request-wins with AbortController
Because cleanup runs before the effect re-runs, every keystroke that changes query first aborts the in-flight request from the previous keystroke, then issues a new one. An aborted fetch's promise rejects with a DOMException named 'AbortError', which must be filtered out of error handling so it doesn't surface as a user-facing error.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setResults(data))
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
// Aborting the PREVIOUS request happens automatically: this
// cleanup runs before the effect re-fires on the next query change.
return () => controller.abort();
}, [query]);Code Examples
Every keystroke fires a new fetch, but nothing stops an earlier request's response from being applied after a later one's. On a real network with variable latency, results for a partial, stale query can flash in or permanently overwrite the correct results.
Common mistake: Assuming that because requests are issued in order, promises will also resolve in order — network timing makes no such guarantee.
Each new query aborts the previous request before it starts. If the server or network already sent the response by the time abort() runs, the browser drops it — the .then chain for the aborted request never calls setResults, so stale data can never win the race.
return () => controller.abort();
This is useful when the underlying API call cannot be aborted (some third-party SDKs, GraphQL clients without abort support, or long-running server jobs). Each request captures its own id at creation time; the response handler only commits state if that id is still the most recent one issued, so an out-of-order response for an old request is silently discarded instead of applied. useRef is used instead of useState for the counter because updating it must not trigger a re-render — it's pure bookkeeping read by the response handlers.
This is the pattern React's own documentation recommends for simple cases: a local boolean captured by closure, flipped to true by cleanup. Because cleanup runs before the effect re-fires on every dependency change, any response belonging to a superseded effect run is ignored. It does not cancel the network request itself (unlike AbortController), it only prevents that request's result from being applied.
TanStack Query keys each request by its queryKey (here, the tuple ['search', query]) and automatically cancels the in-flight request for a stale key when a new one is requested, passing it an AbortSignal under the hood. The component no longer hand-manages AbortController, request ids, or ignore flags at all — the library's cache and query lifecycle absorb that responsibility.
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
if (!query) return;
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => setResults(data)); // BUG: no ordering guarantee
}, [query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
);
}function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
if (!query) return;
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setResults(data))
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
return () => controller.abort();
}, [query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
);
}function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const latestRequestId = useRef(0);
useEffect(() => {
if (!query) return;
const requestId = ++latestRequestId.current;
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => {
// Only apply this response if no newer request has started.
if (requestId === latestRequestId.current) {
setResults(data);
}
});
}, [query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
);
}useEffect(() => {
if (!query) return;
let ignore = false;
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setResults(data);
});
return () => {
ignore = true;
};
}, [query]);function SearchBox() {
const [query, setQuery] = useState("");
const { data: results = [] } = useQuery({
queryKey: ["search", query],
queryFn: ({ signal }) =>
fetch(`/api/search?q=${query}`, { signal }).then((res) => res.json()),
enabled: query.length > 0,
});
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
);
}Out-of-order responses overwriting newer data
Request A is issued first but its response arrives last; without guarding against this, A's handler runs after B's and overwrites the correct, newer state with stale data.
time ─────────────────────────────────────────────────────▶
Request A ───────────────────────────────▶ Response A
Request B ─────────────▶ Response B
▲ ▲
│ │
B finishes first A finishes AFTER B
│
setState(A's old data)
silently overwrites
setState(B's new data)
Result: UI shows stale data from A, even though B was the
more recent request and its response already arrived.Important Rules
Never assume promises resolve in the order their requests were issued; always guard against out-of-order completion whenever more than one async operation targeting the same piece of state can be in flight at once.
Prefer AbortController when the underlying transport supports cancellation (fetch, most HTTP clients) — it stops wasted network/server work in addition to preventing the race.
Use request IDs or the cleanup-flag pattern when the API cannot be cancelled (some SDK calls, long-running jobs, WebSocket request/response protocols).
Always filter out AbortError in catch blocks — it is an expected, intentional rejection, not a real error to surface to the user.
When a project already depends on a query library (TanStack Query, SWR, RTK Query), prefer it over hand-rolled fetch effects for any data that can be re-requested by changing inputs — it eliminates this entire bug class by construction.
Typeahead search is the textbook case
Nearly every production typeahead/autocomplete component has shipped this bug at least once, because it is invisible during manual QA (testers pause between keystrokes) and only appears under real, variable-latency typing speed. Debounce alone does not fix it — debouncing reduces how many requests are sent, but does not guarantee ordering among the requests that are sent, so a debounced search box can still race. The fix must guard the response application step itself, whether via AbortController, request IDs, or a query library.
useEffect(() => { fetch(url).then(r => r.json()).then(setData); }, [url]);
useEffect(() => { const c = new AbortController(); fetch(url, { signal: c.signal }).then(r => r.json()).then(setData); return () => c.abort(); }, [url]);
Without a cancellation or guard mechanism, a slower earlier response can resolve after a faster later one and overwrite it.
fetch(url, { signal: controller.signal }).catch(err => setError(err));
fetch(url, { signal: controller.signal }).catch(err => { if (err.name !== 'AbortError') setError(err); });
Treating an intentional AbortError as a real failure surfaces a confusing error message to the user every time a newer request supersedes an older one.
In the race-condition diagram, why does the UI end up showing stale data from Request A? A. Request A always contains more data than Request B B. Request A's response arrives after Request B's, so A's setState call runs last and overwrites B's newer results C. React batches all setState calls and discards the first one D. The browser automatically retries Request A after Request B completes
B. Request A's response arrives after Request B's, so A's setState call runs last and overwrites B's newer results — Whichever response's handler runs last wins, regardless of which request was issued first — if A's response arrives after B's, A's (stale) data silently overwrites B's (correct, newer) data.
Why must AbortError be filtered out in a .catch() handler after using AbortController? A. AbortError indicates a network failure that must be retried immediately B. It is an expected rejection caused by intentionally cancelling a superseded request, not a real error to show the user C. Filtering it prevents memory leaks D. AbortError only occurs in development builds
B. It is an expected rejection caused by intentionally cancelling a superseded request, not a real error to show the user — Calling controller.abort() deliberately rejects the fetch promise with an AbortError; treating it like any other failure would surface a false error every time a newer request correctly supersedes an older one.
Why doesn't debouncing alone fix a race condition in a typeahead search box? A. Debouncing sends requests in a random order B. Debouncing reduces how many requests are sent, but does not guarantee that the requests it does send resolve in the order they were issued C. Debouncing disables AbortController D. Debounced requests cannot use fetch
B. Debouncing reduces how many requests are sent, but does not guarantee that the requests it does send resolve in the order they were issued — Debouncing only limits request frequency; two debounced requests can still complete out of order on a variable-latency network, so the response-ordering guard is still required.
Refactor a useUserProfile(userId) hook that currently does fetch('/api/users/' + userId).then(r => r.json()).then(setUser) inside a useEffect keyed on userId, so that rapidly switching between user IDs never lets a stale profile overwrite the correct one. Use the request-id pattern (not AbortController).
function useUserProfile(userId: string) { const [user, setUser] = useState<User | null>(null); const latestId = useRef(0); useEffect(() => { const requestId = ++latestId.current; fetch('/api/users/' + userId) .then((r) => r.json()) .then((data) => { if (requestId === latestId.current) setUser(data); }); }, [userId]); return user; }
Increment a ref-held counter each time the effect runs, capture its value locally, and check it still matches the ref before calling setUser.