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
onChangehandlers 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
useEffectthat 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:
useEffectwithfetchand noAbortControllerin the cleanup functionuseEffectwithfetchwhere 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/setIntervalcallbacks that reference state- Event handlers inside
useEffectthat 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/setTimeoutcallbacks that directly reference state variablesuseEffectwith empty dependency array[]that accesses props or state that can change- Event listeners added in
useEffectthat 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
useEffectwithout 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:
useEffectwithfetchand no return function (no cleanup at all)useEffectwithaddEventListener/subscribeand no correspondingremoveEventListener/unsubscribein cleanupsetTimeoutcreated inuseEffectwithoutclearTimeoutin cleanup- Polling patterns (
setIntervalor recursivesetTimeout) 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/isLoadinggate 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.addEventListenerinuseEffectEventSource/ WebSocket connections- Third-party library
.on()/.subscribe()registrations MutationObserver/ResizeObserver/IntersectionObserverinstances
Check for:
useEffectthat 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
fetcherfunctions with side effects can still race mutatewith async updater: if twomutatecalls race, does the second use the optimistic state from the first?useSWRInfinite: does page fetching handle rapid pagination clicks?
React Query:
useQueryhandles cancellation viasignalif thequeryFnuses it — verifyqueryFnactually passes the signal tofetchuseMutationwithonMutateoptimistic update: verifyonErrorrollback uses the context from the correctonMutateinvocation when mutations race
Next.js App Router:
- Server actions: can the user trigger the same server action concurrently?
useFormStatushelps but only within the same<form> router.push/router.replaceduring 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.useEffectcleanup 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.
- Async Operation Inventory — Table:
| Component | Trigger | Async Pattern | Cancellation | Cleanup | Status |
|---|
Status values: Clean, Race Possible, Stale Closure, No Cleanup, Leak
- 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)
- Stale Closure Risks — For each: file:line, the captured variable, the async context, and the fix (functional updater, useRef, dependency fix)
- Missing Cleanup — For each: file:line, the resource not cleaned up (listener, timer, subscription, controller), and the fix
- Concurrent Mutation Risks — For each: file:line, the mutation that can fire concurrently, the consequence, and the fix (disable during pending, queue, cancel previous)
- Positive Findings — Components with correct async patterns that can serve as reference implementations