Cancellation as a Protocol: AbortSignal Beyond fetch()
AbortSignal is not just fetch() cancellation — it is a general protocol for stopping async work, with a typed reason, composable signals, and standard teardown.
Most teams meet AbortSignal as the object you pass to fetch() so a request can be cancelled, then never think about it again.[16] It is actually a general cancellation protocol: one object any asynchronous API can accept, that composes with other signals, that carries a typed reason for why work stopped, and that has a standard way to register — and unregister — the side effects of stopping.[10][15]
One Controller, One Signal, One Shot
AbortController represents a controller object that allows you to abort one or more web requests as and when desired, and its signal property hands out the AbortSignal the operation observes.[4] One abort() call can abort fetch requests, response-body consumption, and streams.[4]
A signal is spent after one use. An AbortSignal can only be used once, and after it is aborted, any fetch call using the same signal will be immediately rejected.[1] So create a fresh controller per operation: a module-scope controller suits a genuine "cancel everything on this page" switch, but reusing it after the first abort yields an instant rejection.[1]
Timing has a second edge. If the request is aborted after the fetch() call has been fulfilled but before the response body has been read, then attempting to read the response body will reject with an AbortError exception.[1] Cancellation covers body consumption too — the window where a large download spends most of its life.[1][4]
The Reason Is the Payload
The reason property is undefined when the signal has not been aborted, and defaults to an "AbortError" DOMException when nothing else was specified.[5] In the specification, a signal's abort reason is set to the reason if it is given, and otherwise to a new "AbortError" DOMException — and an AbortSignal object is aborted when its abort reason is not undefined.[10]
"Aborted" is not a flag sitting next to the reason; it is defined by the reason being present, so you can abort with any JavaScript value.[5][10]
Discriminate on reason, not on the presence of failure: AbortError means someone asked to stop, TimeoutError means a deadline passed.[5][3] One caveat: an API observing the AbortSignal can choose to ignore the change, if the operation has already completed.[10] Cancellation is a request, not a guarantee.
Timeouts That Respect Suspension
AbortSignal.timeout() returns a signal that automatically aborts after a specified time, and the signal aborts with a TimeoutError DOMException on timeout.[3] That separates a user navigating away from a slow server.[3]
The clock is the detail most engineers miss. The timeout is based on active rather than elapsed time, and will effectively be paused if the code is running in a suspended worker, or while the document is in a back-forward cache.[3] A five-second timeout on a backgrounded tab is not a wall-clock deadline.
AbortSignal.timeout() does not provide a way to cancel its timeout, and finishing early or aborting a signal combined with it does not cancel it either.[3] If you need to cancel one early, the documented route is an AbortController with setTimeout(), calling clearTimeout() when the operation finishes.[3]
Composing Signals with any()
Real cancellation usually has more than one source: a user action, a route change, and a deadline. AbortSignal.any() takes an iterable of signals, and the returned abort signal is aborted when any of the input iterable abort signals are aborted, with the reason set to that of the first signal to abort.[2][13]
That composition is one-directional. AbortSignal.any() does not provide a method to unsubscribe the returned signal from its input signals, and aborting the returned signal does not abort the other input signals or cancel their timeouts.[2] The combined signal is a listener, not a parent: it reacts to its sources and cannot command them, linking to them through weak references.[2]
One cost of any(): unlike when using AbortSignal.timeout() on its own, there is no way to tell whether the final abort was caused by a timeout.[1] Inspecting reason restores it, because the reason is the TimeoutError DOMException from the timeout signal.[3][10]
Cancellation in Code That Takes No Signal
Not every API accepts a signal. For code you control, throwIfAborted() throws the signal's abort reason if the signal has been aborted, and otherwise does nothing.[6] The specification's motivating example is a polling loop containing no signal-aware operation: the method is primarily useful for when functions accepting AbortSignals want to throw — or return a rejected promise — at specific checkpoints, instead of passing along the AbortSignal.[10]
For DOM work the signal doubles as a lifecycle handle. addEventListener() accepts a signal option, and the listener will be removed when the abort() method of the AbortController which owns the AbortSignal is called.[8] That replaces manual removeEventListener bookkeeping around component teardown, and a forgotten listener is the default failure mode of hand-written cleanup.[8]
Streams sit between the two. To stop iterating a response body you cancel the fetch() operation using an AbortController and its associated AbortSignal, and the abort event of the AbortSignal is fired when the associated request is aborted.[9][17] The catch is granularity: code in the loop only runs when the stream has new data, so there may be a delay between the abort and the loop exiting.[9]
The Listener Retention Trap
Aborting is also where memory bugs live, and the rule is narrower than most assume. Signals created by AbortControllers are garbage collectible as soon as both the signal and its owning controller become unreachable, even with abort event listeners.[1] The problem is signals whose abortion is managed by something other than a controller, which are kept alive by the existence of an abort event listener.[1]
The trap is { once: true }, which only removes the listener when the event actually fires — if neither input signal aborts, the listener remains even after the response body has been read.[1] Node's documentation recommends the option and warns that failure to use it may result in memory leaks, but on a signal that never aborts the listener stays, and the combined signal it is attached to stays reachable with it.[15] Remove the listener when the operation completes normally.[1]
Support and Rollout Order
The base API is old enough to be uninteresting. AbortController and AbortSignal are supported in Chrome 66, Firefox 57, and Safari 11.1.[12] The Safari 11.1 entry is flagged as a partial implementation because window.AbortController was defined but did not really abort fetch requests until 12.1. The reason property arrived much later, in Chrome 98, Firefox 97, and Safari 15.4, and throwIfAborted() in Chrome 100, Firefox 97, and Safari 15.4.[11] Code that branches on signal.reason is modern code, not baseline code.[5]
The statics are the newest layer. AbortSignal.abort(), shorthand for a controller that is already aborted, landed in Chrome 93, Firefox 88, and Safari 15.[7] AbortSignal.timeout() followed in Chrome 124, Firefox 100, and Safari 16.[11][14]
One compat trap in timeout(): the Chrome entry from 103 to 124 is marked as a partial implementation with the note that it always aborts with an AbortError on timeout, not a TimeoutError, so code that caught TimeoutError to distinguish a deadline from a user cancellation was broken on Chrome for roughly two years.[11]
For rollout, treat the pieces as tiers: ship the controller-and-signal pattern and the addEventListener signal option broadly; gate reason and throwIfAborted() behind a feature check for older browsers; and when you adopt the statics, verify the TimeoutError behaviour rather than assuming it.[8][11][12]
Sources
[1] https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal — AbortSignal - Web APIs | MDN
[2] https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static — AbortSignal: any() static method - Web APIs | MDN
[3] https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static — AbortSignal: timeout() static method - Web APIs | MDN
[4] https://developer.mozilla.org/en-US/docs/Web/API/AbortController — AbortController - Web APIs | MDN
[5] https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason — AbortSignal: reason property - Web APIs | MDN
[6] https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted — AbortSignal: throwIfAborted() method - Web APIs | MDN
[7] https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort_static — AbortSignal: abort() static method - Web APIs | MDN
[8] https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener — EventTarget: addEventListener() method - Web APIs | MDN
[9] https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams — Using readable streams - Web APIs | MDN
[10] https://dom.spec.whatwg.org — DOM Standard - WHATWG
[11] https://raw.githubusercontent.com/mdn/browser-compat-data/main/api/AbortSignal.json — browser-compat-data api/AbortSignal.json - MDN
[12] https://raw.githubusercontent.com/mdn/browser-compat-data/main/api/AbortController.json — browser-compat-data api/AbortController.json - MDN
[13] https://raw.githubusercontent.com/web-platform-dx/web-features/main/features/abortsignal-any.yml — web-features: AbortSignal.any() - web-platform-dx
[14] https://raw.githubusercontent.com/web-platform-dx/web-features/main/features/abortsignal-timeout.yml — web-features: AbortSignal.timeout() - web-platform-dx
[15] https://nodejs.org/api/globals.html — Global objects: Class AbortController - Node.js Documentation
[16] https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch — Using the Fetch API - Web APIs | MDN
[17] https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort_event — AbortSignal: abort event - Web APIs | MDN