Cancelling Async Work with AbortController
Long-running async work — a slow HTTP request, a file read, or a pending timer — often needs to be cancelled before it finishes. Maybe the user navigated away, a deadline elapsed, or another operation already produced the answer you needed. Node.js exposes the web-standard AbortController and AbortSignal primitives for exactly this: a single, composable cancellation mechanism that works across fetch, fs, timers, streams, and your own code. Because the API is standardized, the same patterns you learn here apply identically in browsers, Deno, and Bun.
How AbortController and AbortSignal work
An AbortController is a small object that owns one AbortSignal. You pass the signal into an async operation; you keep the controller. When you call controller.abort(), the signal flips to the aborted state, fires an abort event, and any operation watching it rejects (typically with an AbortError). The split is deliberate: the caller holds the controller and decides when to cancel, while the callee only sees a read-only signal it can observe but not trigger.
const controller = new AbortController();
const { signal } = controller;
console.log(signal.aborted); // false
controller.abort();
console.log(signal.aborted); // true
console.log(signal.reason); // AbortError [or your custom reason]
Output:
false
true
DOMException [AbortError]: This operation was aborted
You can pass a custom reason to abort() — controller.abort(new Error('user navigated away')) — and read it back from signal.reason. If you abort with no argument, Node fills in a default AbortError for you.
Cancelling fetch requests
The most common use case is aborting an in-flight HTTP request. Native fetch accepts a signal option, and the returned promise rejects when the signal aborts.
const controller = new AbortController();
// Cancel the request if it hasn't completed in 3 seconds.
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch('https://api.example.com/slow', {
signal: controller.signal,
});
const data = await res.json();
console.log('Received', data);
} catch (err) {
if (err.name === 'AbortError') {
console.error('Request was cancelled');
} else {
throw err;
}
} finally {
clearTimeout(timer);
}
Output:
Request was cancelled
Always clear the timer in a finally block so a fast, successful response doesn’t leave a dangling timeout that aborts nothing.
AbortSignal.timeout() for deadlines
Wiring up a controller and a setTimeout just to enforce a deadline is boilerplate. AbortSignal.timeout(ms) builds a signal that aborts itself automatically after the given duration — no controller to manage and no timer to clear.
try {
const res = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000),
});
console.log(await res.json());
} catch (err) {
if (err.name === 'TimeoutError') {
console.error('Request timed out after 5s');
}
}
A signal from
AbortSignal.timeout()aborts with aTimeoutError, not anAbortError. Checkerr.name === 'TimeoutError'if you want to distinguish a deadline from a manual cancellation.
Combining signals with AbortSignal.any()
Sometimes an operation should cancel if any of several conditions occurs — say, a user-triggered cancel or a hard timeout. AbortSignal.any([...signals]) returns a single signal that aborts as soon as the first of its inputs aborts, adopting that signal’s reason.
const userCancel = new AbortController();
const combined = AbortSignal.any([
userCancel.signal,
AbortSignal.timeout(10_000),
]);
const res = await fetch('https://api.example.com/report', {
signal: combined,
});
// Elsewhere: userCancel.abort() cancels immediately,
// or the 10s timeout fires first — whichever comes first wins.
This composes cleanly and avoids manually subscribing to multiple signals.
Aborting fs operations and timers
Cancellation isn’t limited to HTTP. Many core modules accept a signal option.
| API | How to pass the signal | Aborts with |
|---|---|---|
fetch(url, { signal }) | option | AbortError |
fs.readFile(path, { signal }) | option | AbortError |
fs.writeFile(path, data, { signal }) | option | AbortError |
setTimeout(fn, ms, opts, { signal }) (from node:timers/promises) | option | AbortError |
stream.pipeline(..., { signal }) | option | AbortError |
import { readFile } from 'node:fs/promises';
import { setTimeout as delay } from 'node:timers/promises';
const controller = new AbortController();
setTimeout(() => controller.abort(), 100); // abort soon
try {
// A promise-based timer that can be cancelled.
await delay(5000, undefined, { signal: controller.signal });
console.log('waited the full 5s'); // never runs
} catch (err) {
if (err.name === 'AbortError') console.error('Delay cancelled early');
}
// fs example
const ctrl = new AbortController();
const reading = readFile('./big.log', { signal: ctrl.signal });
ctrl.abort();
await reading.catch((e) => console.error('Read aborted:', e.name));
Output:
Delay cancelled early
Read aborted: AbortError
In CommonJS, swap the imports for
const { readFile } = require('node:fs/promises')andconst { setTimeout: delay } = require('node:timers/promises'). The signal APIs themselves are global in Node 18+.
Listening for the abort event
When you write your own cancellable function, observe the signal directly. You can subscribe with addEventListener('abort', ...), check signal.aborted up front, or call signal.throwIfAborted() to bail immediately if cancellation already happened.
function waitForResource(signal) {
return new Promise((resolve, reject) => {
signal.throwIfAborted(); // reject right away if already aborted
const id = setInterval(() => {
if (resourceReady()) {
clearInterval(id);
resolve('ready');
}
}, 250);
signal.addEventListener('abort', () => {
clearInterval(id);
reject(signal.reason);
}, { once: true });
});
}
Use { once: true } so the listener auto-removes after firing, and always clean up timers, sockets, or watchers inside the handler to avoid leaks.
Best Practices
- Hold the
AbortControllerin the caller and pass onlysignalto the operation you want to control. - Always check
err.name('AbortError'vs'TimeoutError') to handle cancellation distinctly from real failures. - Prefer
AbortSignal.timeout(ms)over a manual controller plussetTimeoutwhen you only need a deadline. - Use
AbortSignal.any([...])to merge user-cancel and timeout signals instead of subscribing to each one. - Clear timers and remove
abortlisteners infinallyor with{ once: true }to prevent leaks. - Call
signal.throwIfAborted()at the start of long operations to fail fast on an already-aborted signal. - Reuse a single signal across related operations so one
abort()tears down the whole group at once.