Cancellation with AbortController
The standard, composable way to cancel fetches, timers, streams, and your own async work.
Promises have no built-in cancel. The web-standard AbortController fills that gap and is now wired through Node's core APIs — fetch, fs, streams, events.once, and child_process all accept a signal.
The model is simple: a controller owns a signal; calling controller.abort(reason) flips the signal to aborted and fires its 'abort' event. Any operation watching that signal bails out and rejects with an AbortError.
const ac = new AbortController();
// Abort automatically after 2 seconds.
setTimeout(() => ac.abort(new Error('too slow')), 2000);
try {
const res = await fetch('https://example.com', { signal: ac.signal });
console.log(res.status);
} catch (err) {
if (err.name === 'AbortError') console.log('request cancelled');
}Composing signals
AbortSignal.timeout(ms) gives you a fire-and-forget timeout signal, and AbortSignal.any([...]) combines several so any of them can cancel the work — e.g. an incoming request abort OR a global deadline.
Example
// A cancellable, deadline-bounded operation that also honors an
// externally supplied signal (e.g. from the HTTP request lifecycle).
const { setTimeout: sleep } = require('node:timers/promises');
async function withDeadline(ms, externalSignal, work) {
// Cancel when EITHER our deadline fires OR the caller aborts.
const signal = AbortSignal.any([
AbortSignal.timeout(ms),
...(externalSignal ? [externalSignal] : []),
]);
return work(signal);
}
// A worker that respects the signal at every await point.
async function slowJob(signal) {
for (let step = 0; step < 10; step++) {
signal.throwIfAborted(); // fail fast if already cancelled
await sleep(100, undefined, { signal }); // timers/promises honors signals
console.log('step', step);
}
return 'done';
}
const requestAborted = new AbortController();
// e.g. res.on('close', () => requestAborted.abort())
try {
const result = await withDeadline(450, requestAborted.signal, slowJob);
console.log(result);
} catch (err) {
// Distinguish a timeout from a client disconnect via err.cause / reason.
console.log('aborted:', err.name, err.cause?.message ?? err.message);
}When to use it
- A search-as-you-type feature aborts an in-flight `fetch` call with `AbortController` each time the user types a new character so stale responses are ignored.
- A timeout wrapper creates an `AbortController`, schedules `abort()` after 5 seconds, and passes the signal to `fetch` to enforce a hard deadline.
- A graceful-shutdown handler creates a shared `AbortController` and passes its signal to long-running streaming operations so they stop cleanly on SIGTERM.
More examples
Cancel a fetch with AbortController
Creates an `AbortController`, passes its signal to `fetch`, and aborts the request after a timeout.
const controller = new AbortController();
const { signal } = controller;
fetch('https://api.example.com/stream', { signal })
.then(res => res.text())
.then(console.log)
.catch(err => {
if (err.name === 'AbortError') console.log('Request cancelled');
else throw err;
});
// Cancel after 2 seconds
setTimeout(() => controller.abort(), 2000);Timeout any promise with AbortSignal
Uses `AbortSignal.timeout(ms)` (Node 17.3+) to create a self-aborting signal without managing a controller manually.
// Node 17.3+ built-in helper
const signal = AbortSignal.timeout(3000); // auto-aborts after 3s
try {
const res = await fetch('https://slow.api/data', { signal });
const json = await res.json();
console.log(json);
} catch (err) {
if (err.name === 'TimeoutError') console.error('Timed out!');
}Propagate cancellation through async work
Passes an `AbortSignal` into a loop and calls `signal.throwIfAborted()` at each iteration to support cooperative cancellation.
async function processItems(items, signal) {
for (const item of items) {
signal.throwIfAborted(); // throws AbortError if cancelled
await processOne(item);
}
}
const controller = new AbortController();
processItems(largeList, controller.signal).catch(console.error);
// Cancel on SIGINT
process.on('SIGINT', () => controller.abort());
Discussion