Handle Race Conditions in Async JavaScript Calls
Advertisement
🧩 Scenario
Architecture Walkthrough
Why Race Conditions Happen
Network requests take unpredictable time. Two requests started at different times can complete in the reverse order they were started. A search input that fires a new request on every keystroke will issue requests for each intermediate state: "r", "re", "rea", "reac", "react". If the "r" request is slow and resolves after the "react" request, the search results will show results for "r" even though the user typed "react". This stale result appears to jump backward.
The root cause is updating shared mutable state (the UI) inside a callback without verifying that the callback still corresponds to the current intent. Each callback only knows about its own request, not whether a newer one was started after it.
Request Token Pattern
The simplest fix is a monotonically incrementing request counter. Before firing each request, increment the counter and capture the current value in a local variable. After the response arrives, compare the captured value to the current counter. If they match, this is still the most recent request. If they differ, a newer request has been started and this response should be discarded without updating the UI.
This pattern requires zero external dependencies and works in any async context. It does not cancel the in-flight request (the network call still completes), but it prevents the stale data from reaching the UI.
AbortController: Cancel In-Flight Requests
The AbortController API provides actual request cancellation. Before starting a new request, call abort() on the previous controller. The browser will cancel the in-flight fetch, freeing the network connection and preventing the response callback from running. This is more efficient than the token pattern for expensive requests because it saves bandwidth and avoids processing a response that will be discarded.
The controller's signal must be passed to fetch in the options object. When abort() is called, the fetch Promise rejects with an AbortError. You should check err.name === 'AbortError' in the catch block and handle it separately from real errors, since abort is an expected part of the flow.
Key Code Explained
// Pattern 1: Request token — discard stale responses
let requestId = 0;
async function searchWithToken(query) {
const currentId = ++requestId; // capture this request's ID
const results = await fetchSearch(query);
if (currentId !== requestId) {
return; // a newer request was started — discard this response
}
updateSearchResults(results);
}
// Pattern 2: AbortController — cancel in-flight requests
let activeController = null;
async function searchWithAbort(query) {
if (activeController) {
activeController.abort(); // cancel the previous in-flight request
}
activeController = new AbortController();
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: activeController.signal,
});
const results = await res.json();
updateSearchResults(results);
} catch (err) {
if (err.name === 'AbortError') return; // expected — not a real error
throw err;
}
}
// React: combine both patterns inside useEffect
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let cancelled = false; // token for this effect run
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await res.json();
if (!cancelled) setResults(data); // only update if still relevant
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}
load();
return () => {
cancelled = true; // prevent stale setState
controller.abort(); // cancel the network request
};
}, [query]);
}
// Pattern 3: Promise.all — when all concurrent requests must complete together
async function loadDashboard(userId) {
// Both requests run simultaneously; UI only updates when both succeed
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
]);
renderDashboard({ user, posts });
}
The React useEffect pattern is the most important real-world form. The cleanup function runs before the next effect (when query changes) and on unmount, aborting the previous request and setting cancelled = true. This ensures neither the stale data nor the stale setResults call can reach the component's state.
Tradeoffs
| Pattern | Cancels network request | Complexity | Works outside React |
|---|---|---|---|
| Request token | No | Low | Yes |
| AbortController | Yes | Moderate | Yes |
| React useEffect flag | No (unless combined) | Low | No |
| Both combined | Yes | Moderate | Best of both |
What Interviewers Actually Check
- Whether you can explain why network requests do not resolve in the order they were started
- Whether you can implement the request token pattern correctly
- Whether you know
AbortControllerand how to passsignaltofetch - Whether you know to handle
AbortErrorseparately in the catch block - Whether you can apply either pattern inside a React
useEffectwith proper cleanup
Follow-Up Questions
- How would you handle a race condition in a data table where clicking different rows fires different requests?
- What happens if you call
controller.abort()after the fetch has already completed? - How does React Query handle race conditions internally, and does it use AbortController?
- If a request is aborted, does the server still process the request or does it stop immediately?
- How would you write a custom
useFetchhook that handles race conditions for any URL?
Common Candidate Mistakes
- Assuming requests complete in the order they were started and not accounting for variable network latency
- Updating UI state in the
thencallback without checking if the response is still the latest - Using a single
isLoadingboolean as the "fix" when it only prevents multiple simultaneous requests, not the ordering problem - Not knowing that
AbortController.signalmust be passed in thefetchoptions object - Not handling
AbortErrorseparately, causing abort-triggered catches to be treated as real errors and displayed to users
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain what a race condition is in the context of async data fetching?
- Can you implement the request token pattern to discard stale responses?
- Can you implement request cancellation using
AbortController? - Can you explain when
Promise.allis the right solution vs the cancellation token approach? - Can you apply either pattern inside a React
useEffectwith a cleanup function?
Summary
A race condition in async JavaScript occurs when multiple in-flight requests can resolve in any order and each resolution overwrites shared UI state. The result is that an older, slower request can overwrite the result of a newer, faster one, displaying stale data to the user.
Two patterns fix this. The request token pattern increments a counter before each request and checks the counter after the response arrives; if a newer request was started, the stale response is discarded without updating state. The AbortController pattern goes further by actually canceling the in-flight network request, saving bandwidth and connection resources.
In React, the correct implementation uses both a cleanup flag (cancelled = true) and an AbortController inside useEffect, with the cleanup function aborting the request and setting the flag. This ensures that when the dependency changes and the effect re-runs, neither the stale response body nor a stale setState call can reach the component.
Does Promise.all fix race conditions?
For simultaneous requests that must all complete before acting, yes. But for sequential search requests where only the latest matters, you need a cancellation token instead.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement