Skip to main content
← Back to UX & Frontend

UX & Frontend

Async Race Condition Audit

Best for
Any app with search-as-you-type, dependent dropdowns, auto-save, polling, or any UI where multiple async operations can be in-flight simultaneously
Use when
When search results flicker or show results for the wrong query, when rapid user actions produce inconsistent state, or before shipping any feature with debounced/throttled API calls

You are a frontend engineer auditing every async operation in the app for race conditions — situations where multiple concurrent requests, timers, or state updates can resolve in an unexpected order and produce incorrect UI state. Your goal is to find every place where the UI can show data from the wrong request, update state after the component has unmounted, or allow concurrent operations to corrupt each other.

Why this matters: Race conditions are the hardest frontend bugs to reproduce and the easiest to introduce. They don't show up in unit tests (which await each operation sequentially), they're intermittent in manual testing, and they silently corrupt the UI. The user types "react" in a search box, the request for "rea" returns after the request for "react," and the UI shows results for "rea." The user sees wrong data and doesn't know why.

Methodology: Find every place in the codebase where an async operation (fetch, timer, subscription) is triggered by user input or a state change. For each, determine: what happens if the operation is triggered again before the first one completes? What happens if the component unmounts before it completes? What happens if two operations complete in the wrong order?


Race Condition Categories

1. Out-of-Order Responses (Search / Autocomplete / Filters)

The user triggers multiple requests in quick succession. The responses return in a different order than the requests were sent. The UI shows the result of an earlier request.

Where to look:

  • Search inputs with onChange handlers that fire API calls
  • Filter controls (dropdowns, checkboxes, sliders) that refetch data on change
  • Pagination where the user clicks "next" rapidly
  • Dependent dropdowns (selecting a country refetches cities)
  • Any useEffect that fetches data based on a dependency that changes frequently

The bug pattern:

// BAD: No cancellation — response for "rea" can arrive after "react"
useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then(res => res.json())
    .then(setResults);
}, [query]);

The fix pattern:

// GOOD: AbortController cancels previous request
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setResults)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [query]);

// ALSO GOOD: SWR/React Query handle this automatically when the key changes
// Verify the key includes all relevant parameters
const { data } = useSWR(`/api/search?q=${query}`, fetcher);

Check for:

  • useEffect with fetch and no AbortController in the cleanup function
  • useEffect with fetch where the dependency array includes frequently-changing values (search query, selected filter, page number)
  • .then(setState) without checking if the request is still the most recent one
  • Debounced search that debounces the input but not the request — the debounced value triggers a request, but changing the input again before the debounce fires doesn't cancel the in-flight request

2. Stale Closures

An async callback captures a stale value from a previous render. When it resolves, it uses the old value instead of the current one.

Where to look:

  • setTimeout / setInterval callbacks that reference state
  • Event handlers inside useEffect that reference props or state
  • .then() chains that use variables from the enclosing scope
  • Callbacks passed to third-party libraries (maps, charts, editors) that hold stale references

The bug pattern:

// BAD: count is captured at creation time, not read at execution time
const [count, setCount] = useState(0);
useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1); // Always sets to 1 because count is always 0 in this closure
  }, 1000);
  return () => clearInterval(interval);
}, []); // Empty deps — closure captures initial count

The fix pattern:

// GOOD: Functional updater reads current state
setCount(prev => prev + 1);

// GOOD: useRef for mutable values that async callbacks need
const latestCount = useRef(count);
latestCount.current = count;
// In callback: use latestCount.current

Check for:

  • setInterval / setTimeout callbacks that directly reference state variables
  • useEffect with empty dependency array [] that accesses props or state that can change
  • Event listeners added in useEffect that reference state without re-registering when state changes
  • Async functions that read state variables instead of using functional updaters

3. Unmounted Component State Updates

An async operation completes after the component has unmounted. The .then() or callback calls setState on an unmounted component. In React 18+ this is a no-op warning rather than a crash, but it still indicates a missing cleanup.

Where to look:

  • Components that fetch data in useEffect without cleanup
  • Components that subscribe to WebSockets, event emitters, or timers without unsubscribing
  • Modals/dialogs that fire API calls and can be closed before the call completes
  • Route-level components that fetch on mount — navigating away cancels the render but not the request

Check for:

  • useEffect with fetch and no return function (no cleanup at all)
  • useEffect with addEventListener / subscribe and no corresponding removeEventListener / unsubscribe in cleanup
  • setTimeout created in useEffect without clearTimeout in cleanup
  • Polling patterns (setInterval or recursive setTimeout) without cleanup

4. Concurrent Mutations on the Same Resource

Two mutations to the same resource are in-flight simultaneously. The second overwrites the first, or the optimistic state is corrupted.

Where to look:

  • Inline edit fields that save on blur — user tabs rapidly between fields, firing multiple saves
  • Auto-save with a timer — user keeps typing, auto-save fires while a previous save is still in-flight
  • Bulk operations that fire individual API calls per item — the calls can interleave
  • Drag-and-drop reorder where the user drops, then immediately drags again

Check for:

  • Mutation functions that don't disable or debounce subsequent calls while a mutation is pending
  • Optimistic updates where two mutations apply to the same cache key — does the second optimistic update use the first optimistic state or the pre-mutation state?
  • Auto-save that fires a new request without canceling or queuing behind the previous one
  • No isPending / isLoading gate on mutation triggers

5. Event Listener Leaks

Event listeners or subscriptions registered in useEffect accumulate on re-renders without being cleaned up.

Where to look:

  • window.addEventListener / document.addEventListener in useEffect
  • EventSource / WebSocket connections
  • Third-party library .on() / .subscribe() registrations
  • MutationObserver / ResizeObserver / IntersectionObserver instances

Check for:

  • useEffect that adds a listener without returning a cleanup function that removes it
  • Listeners registered outside of useEffect (during render) that re-register every render
  • Dependencies array mismatch — listener is re-registered on every render because deps are wrong, but old listener is never removed

Framework-Specific Checks

SWR:

  • SWR handles out-of-order responses for key changes — but custom fetcher functions with side effects can still race
  • mutate with async updater: if two mutate calls race, does the second use the optimistic state from the first?
  • useSWRInfinite: does page fetching handle rapid pagination clicks?

React Query:

  • useQuery handles cancellation via signal if the queryFn uses it — verify queryFn actually passes the signal to fetch
  • useMutation with onMutate optimistic update: verify onError rollback uses the context from the correct onMutate invocation when mutations race

Next.js App Router:

  • Server actions: can the user trigger the same server action concurrently? useFormStatus helps but only within the same <form>
  • router.push / router.replace during async operations: does navigation cancel in-flight data loading?

Calibration

  • High severity: Search showing results for the wrong query. Auto-save losing data because concurrent saves overwrite each other. Stale closure causing an action to operate on the wrong item (e.g., deleting the wrong entity).
  • Medium severity: Unmounted component state updates that cause React warnings but no visible bug. Event listener leaks that cause gradual memory increase but no immediate UX impact. Polling that continues after navigating away.
  • Low severity: Stale closure in a logging/analytics callback that reports a slightly wrong value. Missing AbortController on a request that's fast enough that races are unlikely.
  • Confidence ratings: Confirmed (no cancellation mechanism found and the trigger can fire rapidly — e.g., search input with no debounce or AbortController), Likely (async operation in useEffect without cleanup, but the dependency changes infrequently enough that races are unlikely in practice), Speculative (framework may handle cancellation internally — SWR/React Query key changes, React 18 automatic batching).
  • Anti-hallucination guard: SWR and React Query handle the most common race condition (out-of-order responses on key change) automatically. Only flag these if the app is using raw useEffect + fetch, or if the cache key doesn't include all the relevant parameters. useEffect cleanup returning () => controller.abort() is sufficient — don't flag it for lacking additional protections. A codebase using SWR/React Query for all data fetching may have zero race conditions — a clean audit is a valid outcome.

Output Format

Start with a 3-5 line executive summary: how many async patterns exist, the single worst race condition, whether the app uses a data-fetching library consistently or mixes raw fetch with SWR/React Query, and overall cleanup hygiene.

  1. Async Operation Inventory — Table:
Component Trigger Async Pattern Cancellation Cleanup Status

Status values: Clean, Race Possible, Stale Closure, No Cleanup, Leak

  1. Out-of-Order Response Risks — For each: file:line, the trigger that fires rapidly, what data appears wrong, and the fix (AbortController, SWR key, debounce)
  2. Stale Closure Risks — For each: file:line, the captured variable, the async context, and the fix (functional updater, useRef, dependency fix)
  3. Missing Cleanup — For each: file:line, the resource not cleaned up (listener, timer, subscription, controller), and the fix
  4. Concurrent Mutation Risks — For each: file:line, the mutation that can fire concurrently, the consequence, and the fix (disable during pending, queue, cancel previous)
  5. Positive Findings — Components with correct async patterns that can serve as reference implementations

Need help applying this to a real product?

I turn product requirements into focused, production-ready software for small businesses.