AbortController

Cancellation with AbortController

Node uses one cancellation protocol everywhere. Create an AbortController, hand its signal to any API that takes one — fetch, fs.readFile, timers/promises, events.once, server.close, spawn — and call controller.abort(reason). The work rejects with the signal's reason, an AbortError by default.

Combining a user cancellation with a time budgetJavaScript
import { addAbortListener } from 'node:events';
import { setTimeout as delay } from 'node:timers/promises';
async function work(label, ms, signal) {
  using _ = addAbortListener(signal, () => console.log(label, 'cleanup: pool slot released'));
  try { console.log(label, await delay(ms, `done after ${ms} ms`, { signal })); }
  catch { console.log(label, signal.reason.name, '|', signal.reason.message); }
}
const user = new AbortController();
const budget = () => AbortSignal.any([user.signal, AbortSignal.timeout(40)]);
await work('a:', 10, budget());
await work('b:', 90, budget());
setTimeout(() => user.abort(new Error('client disconnected')), 15);
await work('c:', 90, budget());
Output
a: done after 10 ms
b: cleanup: pool slot released
b: TimeoutError | The operation was aborted due to timeout
c: cleanup: pool slot released
c: Error | client disconnected

AbortSignal.timeout(ms) aborts itself with a TimeoutError, and AbortSignal.any() follows whichever input aborts first, propagating its reason — the usual server rule, give up when the client leaves or the deadline passes, with no clearTimeout anywhere. addAbortListener() returns a disposable, so using removes the listener when the block ends; a long-lived signal otherwise accumulates listeners. Inside a loop, call signal.throwIfAborted() between iterations: cancellation is cooperative, and nothing interrupts synchronous code for you.