One controller, one signal, every cancellation pattern you actually need — fetch cancellation, timeouts, composed signals, the "abort is not an error" rule, and cleanup in React and Node. Every block is runnable and copy-paste ready.
An AbortController is a remote with one button. The controller.signal is a wire you hand to any operation that's willing to listen. Call controller.abort(reason) and the signal flips to aborted forever — it fires an abort event once, then stays aborted. You never reuse a controller; you make a new one per operation.
const controller = new AbortController();
const { signal } = controller;
signal.addEventListener('abort', () => {
// fires exactly once
console.log('aborted because:', signal.reason);
}, { once: true });
controller.abort(new DOMException('user cancelled', 'AbortError'));
signal.aborted; // true — sticky, forever
signal.throwIfAborted(); // throws signal.reason if aborted; no-op otherwise
AbortController.Pass signal into fetch. When you abort, the in-flight request rejects with an AbortError. The demo below runs an abortable async task that honors the signal exactly like fetch does — start it, then kill it mid-flight.
const controller = new AbortController();
async function load(url, signal) {
const res = await fetch(url, { signal });
return res.json();
}
// somewhere else — e.g. user clicked "cancel", or a new search started
controller.abort(); // the fetch above rejects with a DOMException name="AbortError"
AbortSignal.timeout(ms)A signal that aborts itself after ms milliseconds — no controller, no setTimeout bookkeeping. It aborts with a TimeoutError DOMException (note: not AbortError — that distinction is the whole point of the next two sections).
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
return await res.json();
} catch (err) {
if (err.name === 'TimeoutError') console.log('took too long');
else throw err;
}
AbortSignal.any([...])Compose many reasons into one signal. AbortSignal.any() returns a signal that aborts the moment any input aborts, carrying that input's reason. The classic case: an upload should stop if the user cancels, or the page navigates away, or a deadline passes.
const userCancel = new AbortController();
const signal = AbortSignal.any([
userCancel.signal, // user clicks "stop"
AbortSignal.timeout(10000), // hard deadline
]);
await fetch(url, { signal });
// reason tells you which one won the race:
// AbortError → user cancelled
// TimeoutError → deadline hit
any() there's no flag that says "a timeout did this" — you read it from signal.reason.name. That's why pairing it with AbortSignal.timeout() (which sets TimeoutError) is the idiomatic combo.A cancelled operation did exactly what you asked. Don't surface it as a failure — no red toast, no Sentry event, no retry. Catch, check the name, and swallow the abort while still re-throwing everything else.
try {
const data = await load(url, signal);
render(data);
} catch (err) {
if (err.name === 'AbortError') return; // expected — we cancelled it. say nothing.
showError(err); // a real failure — handle it
}
useEffect cleanup & StrictModeIn dev, React StrictMode mounts → unmounts → mounts every effect to flush out missing cleanup. Without an abort, the first (discarded) request can resolve after the second and overwrite fresh state with stale data. The cleanup function aborts the controller, so the orphaned request dies quietly (caught by the §04 rule).
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => { if (err.name !== 'AbortError') setError(err); });
return () => controller.abort(); // runs on unmount AND before every re-run (id change, StrictMode)
}, [id]);
id flips fast) and StrictMode's double-invoke are the same bug — one abort fixes both.The { signal } option is the underrated half of the API. Pass it to addEventListener / EventEmitter.on / events.once / streams and the runtime removes the listener for you when the signal aborts — one abort tears down a whole subscription graph, no manual removeListener bookkeeping.
const ac = new AbortController();
emitter.on('data', onData,
{ signal: ac.signal });
target.addEventListener('message', onMsg,
{ signal: ac.signal });
// one call detaches every listener above
ac.abort();
import { once } from 'node:events';
try {
const [msg] = await once(emitter,
'ready', { signal: ac.signal });
} catch (err) {
if (err.name === 'AbortError') return;
throw err;
}