Async Race Condition — Out-of-Order Responses
Diagnose and fix race conditions in concurrent async operations where later requests resolve before earlier ones, causing stale data to overwrite fresh results.
Symptoms
- UI flickers: search results briefly show correct answers then revert to stale ones
- Displayed data doesn't match the latest user input
- React: 'Can't perform state update on unmounted component' warning
- Data table shows rows from a previous query after a new search
Possible causes
- Multiple concurrent fetch requests where response order doesn't match request order
- State update from a stale async callback overwriting a newer state value
- Missing cancellation of in-flight requests when input changes
- Unhandled promise resolution after component unmount
Diagnosis steps
Open Network tab and observe request/response timing
Chrome DevTools → Network → sort by 'Waterfall' columnInterpretation
If request B (sent after A) appears before A in the response timeline, you have a race condition. Note the response sizes — small responses often arrive before large ones regardless of send order.
Add request IDs to track state updates
Wrap state updates with a timestamp or incrementing counterInterpretation
Log the request ID alongside each setState call. If you see setState(5) followed by setState(3), a stale response overwrote a newer one.
Throttle network to amplify the race
Network tab → Throttling → 'Slow 3G' or add a server-side artificial delayInterpretation
Slow networks make the race condition reproducible. If it only happens on slow connections, it was always a race — just masked by fast local development.
Check for missing AbortController usage
Audit all fetch calls for signal parametersInterpretation
Without AbortController, in-flight requests continue even after the triggering state (search query, page number) has changed. Each can resolve and apply stale data.
Root cause
Multiple async operations are in flight simultaneously, and their completion order is not guaranteed to match the launch order. The simplest case: user types 'abc' (triggers request 1), then 'abcd' (triggers request 2). If request 2's network latency happens to be lower than request 1's, the response for 'abcd' arrives first, then request 1's response for 'abc' arrives and overwrites the correct state.
Fix
Use AbortController to cancel in-flight requests when a new one is issued, or use a request ID/token pattern to ignore stale responses. In React, the useEffect cleanup function should abort the previous fetch when the effect re-runs or the component unmounts.
Risk note
AbortController.abort() causes the fetch promise to reject with AbortError — this must be caught separately from other errors to avoid polluting error monitoring with noise.
Verify the fix
After implementing request cancellation, type rapidly in a search box while observing the Network tab. In-flight requests from earlier keystrokes should show as '(cancelled)' in the Status column.
What Is an Async Race Condition
A race condition occurs when multiple async operations run concurrently and their completion order differs from their launch order, causing late-arriving results to overwrite correct ones.
The Classic Search Box Example
❌ Bug: No cancellation
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(data => setResults(data)); // may arrive AFTER a newer query's response
}, [query]);
Fix 1: AbortController (Recommended)
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err; // ignore cancellation
});
return () => controller.abort(); // cancel on cleanup (new query or unmount)
}, [query]);
Fix 2: Request ID Token
useEffect(() => {
const requestId = ++requestIdRef.current;
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(data => {
// Only apply if this is still the latest request
if (requestId === requestIdRef.current) {
setResults(data);
}
});
}, [query]);
Fix 3: SWR / TanStack Query (Production)
import { useQuery } from '@tanstack/react-query';
function SearchResults({ query }) {
const { data } = useQuery({
queryKey: ['search', query],
queryFn: () => fetch(`/api/search?q=${query}`).then(r => r.json()),
// Built-in: dedupes requests, cancels stale fetches, ignores old responses
});
return <ResultsList data={data} />;
}
Common Pitfalls
Not Catching AbortError
// ❌ AbortError pollutes error tracking
fetch(url, { signal }).then(setData).catch(setError);
// ✅ Filter out cancellation errors
fetch(url, { signal }).then(setData).catch(err => {
if (err.name !== 'AbortError') setError(err);
});
Shared Mutable Reference
// ❌ Shared array reference — concurrent pushes corrupt state
const shared = [];
await Promise.all(urls.map(url =>
fetch(url).then(r => r.json()).then(d => shared.push(...d))
));
Testing Race Conditions
// Mock API with deliberate delays to simulate race
const mockFetch = (delay, data) =>
new Promise(resolve => setTimeout(() => resolve(data), delay));
// Request A (slow): returns after 200ms
// Request B (fast): returns after 50ms
// Without cancellation: B's data shown, then overwritten by A's
// With cancellation: B's data shown, A's request aborted
Framework Patterns
- React: useEffect cleanup + AbortController (shown above)
- Svelte: Use reactive stores with cancellation tokens
- Vue: Watch property with AbortController in watcher
Performance Implications
Aborting requests correctly reduces bandwidth. The server may continue processing (HTTP doesn’t support client-side abort at TCP level), but the client won’t wait for or parse the response.