dev notes  /  cancellation, done right

Stop the work.
AbortController, in full.

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.

Verified against the 2026 API AbortSignal.timeout() · Node 17.3+ · ~96% browsers AbortSignal.any() · Node 20+ · ~90% browsers
00

The mental model

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.

model.js
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
Rule: a controller is single-use. Aborted is a terminal state. If you need to cancel again, create a fresh AbortController.
01

Cancel a fetch

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.

cancel-fetch.js
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"
02

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).

timeout.js
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;
}
timeout(ms)
03

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.

compose.js
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
deadline(ms)
Heads up: with 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.
04

Abort is not an error

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.

handle.js
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
}
05

React: useEffect cleanup & StrictMode

In 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).

useUser.jsx
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]);
Why it matters: the cleanup fires before the next effect run, not just on final unmount. Race-on-prop-change (id flips fast) and StrictMode's double-invoke are the same bug — one abort fixes both.
06

Node: stream & event-listener cleanup

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.

listeners.js
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();
stream.js
import { once } from 'node:events';

try {
  const [msg] = await once(emitter,
    'ready', { signal: ac.signal });
} catch (err) {
  if (err.name === 'AbortError') return;
  throw err;
}