← Debugging Guides

阅读中文版本

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.

intermediatedebuggingasyncrace-conditionbrowser

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' column

Interpretation

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 counter

Interpretation

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 delay

Interpretation

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 parameters

Interpretation

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]);
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.