How I Fixed Race Conditions in React Data Fetching With AbortController
Stale responses were overwriting fresh search results. Here is how AbortController fixed it in React, and why I now use it on the server too.
Admin
6 min read·
A few weeks ago I was testing a product search box on a slow 3G throttle in Chrome DevTools. I typed "keyboard", paused, then quickly changed it to "mouse". The results list flickered to mice… and then, a second later, flipped back to keyboards. The input said "mouse". The UI said "keyboard". Nothing threw an error, and nothing showed up in the console.
That is a classic race condition in client-side data fetching, and it is one of those bugs that almost never shows up on a fast local machine. In this post I will walk through why it happens, how I fixed it with AbortController, and a few other places the same API has quietly cleaned up my code, including on the server.
Why the wrong response wins
Here is a simplified version of the component I had:
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Product[]>([]);
useEffect(() => {
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((res) => res.json())
.then((data) => setResults(data.items));
}, [query]);
return <ResultList items={results} />;
}
Every time query changes, the effect fires a new request. The problem is that nothing guarantees responses come back in the order the requests went out. The request for "keyboard" might hit a cold cache or a slower database path, while "mouse" returns quickly. The sequence becomes:
- Request A ("keyboard") starts.
- Request B ("mouse") starts.
- B resolves, and the UI shows mice. Correct.
- A resolves late, and the UI shows keyboards. Wrong.
The last response to arrive wins, not the last request sent. On top of that, if the user navigates away before a request finishes, we still call setResults on a component that no longer exists, and we keep paying for network work nobody needs.
The fix: cancel the request you no longer care about
AbortController is a standard Web API supported by all modern browsers and by Node.js. You create a controller, pass its signal to fetch, and call abort() when you want to cancel. When that happens, the pending fetch promise rejects instead of resolving, so the stale response never reaches your state.
React's effect cleanup is exactly the right moment to call abort(): it runs before the effect re-runs with a new query, and when the component unmounts.
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Product[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
const data: { items: Product[] } = await res.json();
setResults(data.items);
setError(null);
} catch (err) {
// The request was cancelled on purpose: not an error worth showing.
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Unknown error");
}
}
load();
// Runs before the next effect (query changed) and on unmount.
return () => controller.abort();
}, [query]);
if (error) return <p role="alert">{error}</p>;
return <ResultList items={results} />;
}
Now the timeline looks different. When the query changes from "keyboard" to "mouse", React runs the cleanup for the old effect, which aborts request A. Even if the server has already started sending the "keyboard" response, the promise rejects and setResults is never called with it.
Why I check signal.aborted instead of the error name
A lot of examples check err.name === "AbortError". That works when you call abort() with no arguments, because the rejection is a DOMException named AbortError. But abort(reason) accepts an optional reason, and if you pass one, fetch rejects with that reason instead. A timeout signal also rejects with a TimeoutError, not an AbortError.
Checking controller.signal.aborted asks the question I actually care about: "did I cancel this?" It stays correct no matter what reason was used, and it keeps real network errors (DNS failures, CORS problems, a 500 converted to an exception) flowing to the error state.
This also makes Strict Mode happy
In development, React Strict Mode mounts components, runs effects, cleans them up, and runs them again to surface missing cleanup logic. Before this change, I saw two identical search requests in the Network tab on every mount and assumed it was "just Strict Mode". With the abort in place, the first request shows up as canceled and only the second one updates state. That is Strict Mode doing its job: it was pointing at exactly this bug.
Wrapping it in a reusable hook
Once I had the pattern in three components, I pulled it into a small hook. It uses a discriminated union for state so TypeScript forces callers to handle loading and error cases:
import { useEffect, useState } from "react";
type FetchState<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
export function useAbortableFetch<T>(url: string | null): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: "loading" });
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState({ status: "loading" });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => setState({ status: "success", data }))
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setState({
status: "error",
error: error instanceof Error ? error : new Error(String(error)),
});
});
return () => controller.abort();
}, [url]);
return state;
}
For a lot of apps, a library like TanStack Query or SWR is a better long-term answer because it adds caching, deduplication and retries on top of this. TanStack Query, for example, passes an AbortSignal into your query function so you can forward it to fetch. But understanding the raw pattern makes it much easier to reason about what those libraries are doing for you, and for a single search box or a small dashboard, a twenty-line hook is often enough.
Debounce is not a replacement
My first instinct was to "fix" the bug by debouncing the input by 300ms. Debouncing is worth doing because it reduces the number of requests, but it does not fix the race. If the user pauses for 400ms, types again, and the first request is slow, you are right back where you started. I use both: debounce to send fewer requests, abort to make sure only the latest one can win.
Using the same signal on the server
AbortController is not just a browser feature. On the server side, two static helpers have become my default for outbound calls:
- AbortSignal.timeout(ms) returns a signal that aborts automatically after the given time, rejecting with a TimeoutError.
- AbortSignal.any([...signals]) returns a signal that aborts as soon as any of the input signals aborts.
In a Next.js Route Handler, the incoming Request exposes its own signal. Combining it with a timeout means I stop waiting on a slow upstream API either when it takes too long or when the client has gone away:
// app/api/weather/route.ts (Next.js Route Handler)
export async function GET(request: Request) {
// Give the upstream API 3 seconds, and also stop if the client disconnects.
const signal = AbortSignal.any([
request.signal,
AbortSignal.timeout(3000),
]);
try {
const upstream = await fetch("https://api.example.com/weather?city=Tashkent", {
signal,
});
if (!upstream.ok) {
return Response.json({ error: "Upstream error" }, { status: 502 });
}
return Response.json(await upstream.json());
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
return Response.json({ error: "Upstream timed out" }, { status: 504 });
}
throw err;
}
}
Before this, a hung third-party API could tie up a request for as long as the platform allowed. Now the worst case is three seconds and a clean 504, which is much easier to monitor and alert on. Exactly when request.signal fires on disconnect depends on your runtime and hosting platform, so I treat it as a bonus and rely on the timeout as the guarantee.
Bonus: removing event listeners in one line
The same signal works with addEventListener. Instead of keeping references to every handler so you can call removeEventListener later, pass the signal as an option and abort once:
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
window.addEventListener("resize", onResize, { signal });
window.addEventListener("keydown", onKeyDown, { signal });
document.addEventListener("visibilitychange", onVisibility, { signal });
// One call removes all three listeners.
return () => controller.abort();
}, []);
This has removed a whole class of "forgot to unsubscribe one listener" bugs from my effects, and it reads much more clearly than three matching removeEventListener calls.
A quick checklist I now use
- Every fetch inside a useEffect gets an AbortController and a cleanup that calls abort().
- Catch blocks check signal.aborted before treating something as a real error.
- Every outbound server-side request has a timeout via AbortSignal.timeout.
- When I see duplicate requests in development, I ask what Strict Mode is trying to tell me before ignoring it.
Key takeaways
- Responses do not arrive in order. Without cancellation, the slowest stale response can overwrite fresh data.
- Abort in the effect cleanup. It runs before the next effect and on unmount, which is exactly when a request becomes irrelevant.
- Check signal.aborted, not just the error name, so custom reasons and timeouts are handled correctly.
- Debouncing reduces requests but does not fix races; use it alongside aborting, not instead of it.
- On the server, combine AbortSignal.timeout() and AbortSignal.any() to put a hard upper bound on slow upstream calls.
Written by Admin
Published October 5, 2026 · Updated Oct 7, 2026