Headline: AbortController is the web platform's single cancellation primitive: one controller produces one AbortSignal, and that signal plugs into fetch, addEventListener, and most modern data libraries. Four patterns — abort-on-cleanup in useEffect,
AbortSignal.timeout()for deadlines,AbortSignal.any()to merge cancel sources, and thesignaloption for bulk listener removal — eliminated a whole class of race conditions in my React apps.
Key takeaways
-
AbortControllercreates anAbortSignal; callingcontroller.abort(reason)flipssignal.abortedtotrueand rejects any in-flightfetchthat was given that signal. - An AbortController is one-shot. Once aborted it stays aborted, so every new request needs a fresh controller.
-
AbortSignal.timeout(ms)returns a signal that aborts with aTimeoutErrorDOMException — it replaces hand-rolledsetTimeout+abort()+clearTimeoutwiring. -
AbortSignal.any([a, b])merges cancellation sources: the returned signal aborts as soon as the first input signal aborts. - Aborting a request cancels the response, not the server-side work. Never use abort to undo a write; abort reads freely.
What is AbortController and how does the signal actually work?
AbortController is a built-in browser and Node.js class that produces a single AbortSignal, and that signal is the web platform's standard way to say stop this work. I create a controller, hand its signal to any API that accepts one, and call controller.abort(reason) when the work is no longer needed. Every consumer of the signal reacts at once: an in-flight fetch rejects, listeners registered with the signal are removed, and my own code can check signal.aborted or call signal.throwIfAborted() inside a loop.
const controller = new AbortController();
controller.signal.aborted; // false
controller.abort(new Error('user navigated away'));
controller.signal.aborted; // true
controller.signal.reason; // Error: user navigated away
The part that trips people up: a controller is one-shot. There is no reset. Once abort() has been called, that signal is aborted forever, and any fetch started with it fails immediately. Every new request gets a new AbortController.
How do I cancel a stale fetch in a React useEffect?
The classic race condition: a search box fires one request per keystroke, and a slow early response resolves after a fast later one, overwriting fresh results with stale data. The fix is to abort the previous request in the effect cleanup, so only the latest request can ever reach setState.
useEffect(() => {
const controller = new AbortController();
fetch('/api/search?q=' + query, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [query]);
React runs the cleanup before re-running the effect, so fast typing aborts each stale request the moment the next one starts. The same cleanup fires on unmount, which kills the setState-after-unmount class of warnings at the source instead of guarding with an isMounted flag. TanStack Query does this automatically — the queryFn receives { signal }, and passing it through to fetch is all it takes.
What does AbortSignal.timeout() replace?
AbortSignal.timeout(ms) returns a signal that aborts automatically after the given number of milliseconds. It replaces the pattern most codebases hand-rolled for years: create a controller, call setTimeout(() => controller.abort(), ms), then remember to clearTimeout on success. Three lines of lifecycle management collapse into one expression.
const res = await fetch('/api/report', { signal: AbortSignal.timeout(8000) });
Two details matter. First, a timeout signal aborts with a TimeoutError DOMException, while a manual abort() produces an AbortError — error handling can tell a deadline from a deliberate cancel. Second, AbortSignal.timeout() works in all modern browsers and in Node.js 18+, so the same code runs in route handlers and server-side jobs too.
How do I combine a timeout with a user cancel button?
AbortSignal.any(signals) takes an array of signals and returns one that aborts as soon as any input aborts — the cancellation equivalent of Promise.race(). My most common use is a long AI generation request that should stop when the user clicks cancel or when a hard deadline passes, whichever comes first.
const controller = new AbortController(); // wired to a cancel button
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]);
const res = await fetch('/api/generate', { signal });
The merged signal's reason comes from whichever source fired first, so err.name still says whether the user cancelled (AbortError) or the deadline hit (TimeoutError). AbortSignal.any() is newer than the rest of the API — Chrome 116+, Safari 17.4+, Firefox 124+, Node.js 20.3+ — but that covers evergreen browsers, and I use it without a polyfill.
Can one signal remove many event listeners at once?
Yes. addEventListener accepts a signal option, and aborting that signal removes every listener registered with it. This is my default for drag interactions, keyboard-shortcut scopes, and any widget that attaches listeners to window or document: no stored function references, no matching removeEventListener calls, one abort() tears everything down.
const controller = new AbortController();
window.addEventListener('resize', onResize, { signal: controller.signal });
window.addEventListener('scroll', onScroll, { signal: controller.signal });
element.addEventListener('pointermove', onMove, { signal: controller.signal });
// teardown — all three listeners removed at once
controller.abort();
In React this pairs cleanly with useEffect: register every listener with one signal, return () => controller.abort() as the cleanup, and teardown can never drift out of sync with setup.
How do I tell a cancellation from a real failure?
An aborted fetch rejects, so cancellations land in the same catch block as genuine network errors — and reporting them blindly fills the error tracker with noise every time a user navigates away mid-request. The discriminator is the error's name property.
try {
const res = await fetch(url, { signal });
return await res.json();
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return; // expected: we cancelled
if (err instanceof DOMException && err.name === 'TimeoutError') {
return reportTimeout(url); // deadline hit — worth counting
}
throw err; // real failure
}
Passing a custom value to abort(reason) makes that value the rejection instead of the default DOMException. That is useful when the abort should carry context, but it also means an AbortError name check will not match — pick one convention per codebase and stick to it.
When should I not abort a request?
Aborting cancels the response, not the server-side work. By the time abort() runs on a POST, the server may already have committed the write — closing the connection rolls nothing back. My rule: abort reads freely and aggressively; let writes finish. When a user genuinely needs to cancel a write, an idempotency key plus an explicit compensating request is the honest design; an aborted POST that maybe-committed is not. The rule has a server-side mirror: in a Next.js route handler, request.signal aborts when the client disconnects, and long streaming work should check it so the server stops paying for output nobody is reading.
FAQ
Q: Does aborting a fetch cancel the request on the server?
A: No. abort() closes the client connection, but work the server already started may run to completion. Treat abort as client-side cleanup, and design writes with idempotency keys if cancellation matters.
Q: Can I reuse an AbortController after calling abort()?
A: No. A controller is one-shot — once aborted, its signal stays aborted and any fetch given that signal rejects immediately. Create a new controller per request.
Q: What error does an aborted fetch throw?
A: A DOMException named AbortError for a manual abort(), or TimeoutError when the signal came from AbortSignal.timeout(). A custom reason passed to abort(reason) becomes the rejection value instead.
Q: Does TanStack Query use AbortController under the hood?
A: Yes. The queryFn receives an object containing signal; pass it to fetch and superseded or unmounted queries are aborted automatically.
Q: Is AbortSignal.any() safe to use without a polyfill?
A: For evergreen targets in 2026, yes: it shipped in Chrome 116, Safari 17.4, Firefox 124, and Node.js 20.3. For older browsers, feature-detect and fall back to a single controller.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)