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.
Interview Q&A
Q: What is AbortController?
A: 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) => {
if (e.name === "AbortError") return; // ignore, we cancelled
throw e;
});
controller.abort(); // cancels the fetch
AbortSignal also works with addEventListener, setTimeout (via AbortSignal.timeout()), ReadableStream, and other modern APIs.
Q: The classic typeahead race — show the broken and fixed versions.
A:
// 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; });
return () => controller.abort(); // cleanup runs before next effect
}, [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.
Q: What if AbortController isn’t enough — the server already responded?
A: 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) => {
if (myGen === genRef.current) setResults(data); // ignore stale
})
.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.
Q: Debounce vs throttle vs abort — when each?
A:
| 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.
Q: How do you abort on route change in Next/React Router?
A: 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]);
Q: How does request dedup work in TanStack Query?
A: 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 02_tanstack_query.md).
Q: What’s AbortSignal.timeout(ms)?
A: 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.
Q: Aborting a fetch doesn’t abort the server’s work — does it matter?
A: 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
- “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): 02_tanstack_query.md
- Retries vs cancels: 06_retries_backoff_idempotency.md
- Server-side cancellation (signal propagation): ../../backend/06_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/