AbortController, Request Dedup, Race Conditions
TL;DR
The classic frontend bug: user types “ab” → request A fires; user types “abc” → request B fires; A’s response arrives after B’s, the UI now shows results for “ab.” That’s a race condition caused by treating responses as ordered. Fix it with AbortController (cancel A when B starts) and/or by ignoring late responses with a generation counter. TanStack Query handles most of this for you, but every senior should be able to explain — and fix — the bug without a library.
In depth
What is AbortController?
A standard browser API that lets you cancel any operation accepting an AbortSignal — most importantly fetch. When you call controller.abort(), the in-flight request rejects with an AbortError.
const controller = new AbortController();
fetch("/api/search?q=ab", { signal: controller.signal })
.then(r => r.json())
.catch((e) => {
// ignore, we cancelled
if (e.name === "AbortError") return;
throw e;
});
controller.abort(); // cancels the fetchAbortSignal also works with addEventListener, setTimeout (via AbortSignal.timeout()), ReadableStream, and other modern APIs.
The classic typeahead race — show the broken and fixed versions.
// BROKEN — last response wins, not last request
function useSearch(q: string) {
const [results, setResults] = useState<Item[]>([]);
useEffect(() => {
fetch(`/api/search?q=${q}`).then(r => r.json()).then(setResults);
}, [q]);
return results;
}User types fast: A (“ab”), B (“abc”), C (“abcd”). If A is slow, A’s response arrives last and overwrites C’s results.
// FIXED — abort the previous request on each new one
function useSearch(q: string) {
const [results, setResults] = useState<Item[]>([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${q}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch((e) => { if (e.name !== "AbortError") throw e; });
// cleanup runs before next effect
return () => controller.abort();
}, [q]);
return results;
}The useEffect cleanup runs before the next effect when q changes — so request A is aborted the moment B starts. Plus on unmount the final pending request is aborted too.
What if AbortController isn’t enough — the server already responded?
AbortController cancels the network request and the in-flight fetch promise, but if A’s response is already on the wire when B starts, A may still resolve. Belt-and-braces approach: a generation counter that ignores late responses:
function useSearch(q: string) {
const [results, setResults] = useState<Item[]>([]);
const genRef = useRef(0);
useEffect(() => {
const myGen = ++genRef.current;
const controller = new AbortController();
fetch(`/api/search?q=${q}`, { signal: controller.signal })
.then(r => r.json())
.then((data) => {
// ignore stale
if (myGen === genRef.current) setResults(data);
})
.catch(() => {});
return () => controller.abort();
}, [q]);
return results;
}AbortController for the network, generation counter for the state update. TanStack Query does both internally — keyed by queryKey, late responses for a key whose subscriber moved on are dropped.
Debounce vs throttle vs abort — when each?
| Effect | Use for | |
|---|---|---|
| Debounce | wait N ms of quiet, then fire | typeahead — don’t ping per keystroke |
| Throttle | fire at most once per N ms | scroll/resize handlers |
| Abort | fire immediately; cancel the previous in-flight | typeahead with fast responses, or for cancelling on navigation |
Production typeahead combines debounce 200ms (skip in-progress typing) + abort (cancel the previous if the user keeps typing). Debounce reduces request count; abort handles late arrivals.
How do you abort on route change in Next/React Router?
Tie the controller to a useEffect keyed on the route; the cleanup aborts the request when the user navigates.
const params = useParams();
useEffect(() => {
const controller = new AbortController();
fetch(`/api/items/${params.id}`, { signal: controller.signal })
.then(...);
return () => controller.abort();
}, [params.id]);How does request dedup work in TanStack Query?
If two components mount with the same queryKey at the same time, TanStack Query fires one fetch and shares the result. Internally it tracks in-flight promises per key — subsequent subscribers join the same promise. This is dedup across components, not across keys (you still need cursors/keys to be deterministic — see TanStack Query — Cache Keys, Invalidation, Prefetch).
What’s AbortSignal.timeout(ms)?
A built-in signal that aborts after a timeout — no manual setTimeout + controller.abort() plumbing.
const res = await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });AbortSignal.any([sig1, sig2]) combines multiple signals — useful when you want to abort on either timeout or user cancel.
Aborting a fetch doesn’t abort the server’s work — does it matter?
For browser perf and UX, no — the client doesn’t care once it’s cancelled. For server load, sometimes yes — if you’re paying for the request, the server keeps doing the work. Long-running endpoints can listen for the request.signal.aborted event on the server side (Node/Edge runtimes) and short-circuit. Mostly a backend concern, but a senior should know the connection.
Gotchas / edge cases
- Forgetting the
AbortErrorcatch — your error reporter logs every cancellation as a real error. Always filter. - Aborting after
fetchresolved but before.json()—.json()also respects the signal (since it reads the body stream); cancellation can throw inside the JSON parse. Catch it the same way. - Multiple effects sharing one controller — don’t. One controller per effect (or per logical request).
- React 18 StrictMode double-invokes effects in dev — the first invocation’s cleanup will abort the first fetch, the second invocation re-fetches. This is intentional; it surfaces missing cleanups. Don’t disable StrictMode to “fix” it — fix the missing cleanup.
- Memoised
fetchresults — caching at the fetch layer + abort doesn’t mix well; cancel-then-cache is awkward. Cache at a higher layer (TanStack Query). useEffectcleanup runs on dep change, not before — actually it runs both ways: cleanup of the previous run executes before the next run’s effect body. That ordering is what makes the abort pattern work.
What a senior is expected to say 4
- “Last-response-wins is the bug. Abort the previous request when a new one starts, and use a generation counter to ignore late responses that escaped the abort.”
- “Debounce + abort combined — debounce reduces fire-rate, abort handles the in-flight you couldn’t cancel before.”
- “
AbortControllerworks forfetch,addEventListener, modern stream APIs — anywhere that acceptsAbortSignal. I useAbortSignal.timeoutinstead of manual setTimeout plumbing.” - “TanStack Query handles this for me by key, but I know the underlying pattern in case I’m working without a library.”
Cross-references
- TanStack Query (where dedup lives): TanStack Query — Cache Keys, Invalidation, Prefetch
- Retries vs cancels: Retries, Backoff, and Idempotency (from the Frontend)
- Server-side cancellation (signal propagation): Web frameworks
Further reading
- MDN —
AbortController: https://developer.mozilla.org/en-US/docs/Web/API/AbortController - MDN —
AbortSignal.timeout(): https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static - “Beware of useEffect’s stale closures” — Dan Abramov: https://overreacted.io/a-complete-guide-to-useeffect/