Abort Signals Are a Composable Cancellation Primitive
How AbortController coordinates fetches, timers, event listeners, and higher-level operations without owning them.
JavaScript promises deliberately have no universal cancel() method. A promise represents an eventual result, while cancellation belongs to the operation producing that result. AbortController bridges the two ideas with a signal that many operations can observe.
The controller is the writable side. Its signal is the read-only capability passed to consumers:
const controller = new AbortController();
const responsePromise = fetch('/api/report', {
signal: controller.signal,
});
cancelButton.addEventListener('click', () => controller.abort());
Calling abort() changes signal.aborted to true, records a reason, and dispatches an abort event. The signal does not forcibly interrupt arbitrary JavaScript. Each API must define what observing cancellation means.
One signal can describe one operation
A useful design passes the same signal through all work belonging to a user action. Fetch supports it directly, as do modern event listener options and promise-based timer APIs in Node.js.
async function loadDashboard(signal) {
signal.throwIfAborted();
const [profile, activity] = await Promise.all([
fetch('/api/profile', { signal }).then((r) => r.json()),
fetch('/api/activity', { signal }).then((r) => r.json()),
]);
return { profile, activity };
}
If the controller aborts, both fetches observe the same state. throwIfAborted() is useful before expensive synchronous setup or between awaited stages that do not accept a signal themselves.
An AbortSignal is reusable after cancellation. Once the aborted work has settled, call controller.abort() without arguments to clear its state, then pass the same signal to the next operation. Reusing controllers avoids allocating one for every search or navigation.
Reasons preserve intent
controller.abort(reason) can carry an application-specific value. Without an explicit reason, the platform supplies a DOMException whose name is AbortError.
const controller = new AbortController();
controller.abort(new Error('superseded by a newer search'));
try {
controller.signal.throwIfAborted();
} catch (error) {
console.error(error.message);
}
Code should normally distinguish cancellation from failure. A cancelled autocomplete request may require no UI error at all; a network failure probably does. When accepting a caller-provided signal, preserve its reason instead of replacing it with a generic message.
Signals compose
AbortSignal.timeout(milliseconds) creates a signal that aborts after a deadline. AbortSignal.any(signals) creates one that aborts when the first input signal does. Together they express “stop when the caller leaves or when the deadline expires” without manual event wiring.
async function loadWithDeadline(url, callerSignal) {
const signal = AbortSignal.any([
callerSignal,
AbortSignal.timeout(5_000),
]);
return fetch(url, { signal });
}
Composition is preferable to transferring ownership of a controller. A library should generally accept a signal, not a controller, because the caller decides when the larger operation is no longer useful.
Fetch cancellation has layers
Aborting before fetch() settles rejects its promise. Aborting after headers arrive but before the body is consumed causes body-reading methods such as response.json() to reject. Once a response has been fully consumed, aborting the signal cannot undo application work already performed.
Whenever a fetch is aborted, the browser must close the underlying network connection. This guarantee is useful for capacity planning: cancelling ten requests immediately releases ten connection slots rather than leaving transport reuse to the browser.
Cancellation is cooperative all the way down. Custom asynchronous functions should check an already-aborted signal, subscribe with { once: true }, remove any other resources during cleanup, and reject with the signal’s reason. That turns a small browser API into a consistent lifecycle protocol across an application.