Long running tasks, asynchronous tasks, and operations that have a set up and tear down phase, can make use of a
concept called cancellation signals. If you've used other programming languages you might be familiar with objects
like CancellationToken
(or Go's context
) - JavaScript's equivalent is AbortSignal
which can be operated with an AbortController
.
The AbortController
& AbortSignal
APIs can help manage operational "life time". Some examples:
- Long running or asynchronous tasks, for example timers or network fetches
- Overlapping operations, for example canceling in-flight network fetches to replace them with newer ones
- Set up and tear down, for example a Web Components
connectedCallback
anddisconnectedCallback
AbortSignal AbortController Pattern
Cancellation Signals get given to APIs so they know when to abort. An AbortSignal
is created by a controller
(new AbortSignal()
will throw an error). Controllers allow you to make the decision of when a Cancellation Signal
changes. Creating an AbortController
will also create a new AbortSignal
, accessible via .signal
. Code that has
access to the controller can decide when it should be aborted (by calling .abort()
), while code that has access to
the signal can be notified of the abort. To make a new AbortController
, call new AbortContoller()
. The constructor
takes no arguments.
An AbortSignal
s .aborted
property will be true
if the signal has been aborted - you can periodically check that to
stop any work that is about to be done. AbortSignal
is also an EventTarget
- it emits an abort
event which you can
listen to and invoke your tear down.
You can also create some basic controller-free signals that follow some common patterns. For example
AbortSignal.timeout(1000)
will create a cancellation signal that aborts after 1000 milliseconds. These
controller-free signals cannot be manually aborted. You can combine controller-free and controllable signals with
AbortSignal.any([...signals])
.
Using Cancellation Signals internally manage your private APIs
Cancellation Signals can be used to manage internal state that you might have. You can create an AbortController
as
part of your private state, and make use of signals to control behavior. Consumers of your component won't pass these
signals to you, instead you can use them to track a tasks state internally.
A component with start()
and stop()
functions can make the stop()
function abort the controller, and the start()
function create the controller, while checking if the signal has been aborted during an asynchronous loop like so:
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } shadowRoot = this.attachShadow({ mode: "open" }) // Set up the class field, but set it to null for now #startStopController = null async start() { // Stop the current task if there was one this.stop() // Create a new internal controller, and get the signal this.#startStopController = new AbortController() const signal = this.#startStopController.signal // Loop until `signal.aborted` is `true`. while (!signal.aborted) { // Automatically stop when this element is disconnected if (!this.isConnected) return const milliseconds = Date.now() - this.#start const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0") const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0") const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0") this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`) // Schedule next update by awaiting an animation frame await new Promise((resolve) => requestAnimationFrame(resolve)) } } stop() { // Stop aborts the startStopController if it exists this.#startStopController?.abort() } }
Using Cancellation Signals in your own public APIs
If you can use a signal as part of your internal state, it might be simpler to add it as part of the public API. If you are considering using cancellation signals in a public API, it's a good idea to make them an optional part of your API as they won't always be needed.
A component using cancellation signals no longer needs separate start & stop methods, instead combining into one and relying on the signal to know when to stop. This can often simplify code as there's no need to track state across different methods.
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } shadowRoot = this.attachShadow({ mode: "open" }) async start({ signal } = {}) { // Loop until `signal.aborted` is `true`. // If `signal` doesn't exist, then loop forever. // Uses the optional chaining operator to safely check if signal exists while (!signal?.aborted) { // Automatically stop when this element is disconnected if (!this.isConnected) return const milliseconds = Date.now() - this.#start const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0") const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0") const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0") this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`) // Schedule next update by awaiting an animation frame await new Promise((resolve) => requestAnimationFrame(resolve)) } } }
Combining multiple Cancellation Signals
It's possible to combine multiple sources of cancellation signals - for example combining internal and external
cancellation signals to allow for multiple flavors of API. Two or more cancellation signals can be joined into one
using AbortSignal.any()
, which creates a new signal that aborts when any of the given cancellation signals abort.
It's similar to Promise.any()
, but for AbortSignal
.
A component can offer the more traditional start()
and stop()
APIs, as well allowing cancellation signals to be
passed via start({ signal })
. Making use of internal and external cancellation signals, with AbortSignal.any()
:
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } shadowRoot = this.attachShadow({ mode: "open" }) #startStopController = null async start({ signal } = {}) { // Stop the current task if there was one this.stop() // Create a new internal controller this.#startStopController = new AbortController() // Collect all valid signals const signals = [this.#startStopController.signal, signal].filter((s) => s) const signal = AbortSignal.any(signals) // Loop until `signal.aborted` is `true`. while (!signal.aborted) { // Automatically stop when this element is disconnected if (!this.isConnected) return const milliseconds = Date.now() - this.#start const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0") const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0") const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0") this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`) // Schedule next update by awaiting an animation frame await new Promise((resolve) => requestAnimationFrame(resolve)) } } stop() { this.#startStopController?.abort() } }
Using Cancellation Signals to clean up disconnectedCallback()
Web Components that use the connectedCallback()
lifecycle hook to set things up typically want to tear down those
same things in the disconnectedCallback()
, but this can sometimes get a little unwieldy. Instead of mirroring
everything in disconnectedCallback()
, using an AbortController
can reduce disconnectedCallback()
down to one line
of code. APIs called in connectedCallback
will get given the signal, and disconnectedCallback()
only calls
abort()
.
APIs like addEventListener
accept a signal
option. When an Event Listeners signal is aborted, the event
listener will be removed (just like calling removeEventListener
).
class StopWatchElement extends HTMLElement { static define(tag = "stop-watch") { customElements.define(tag, this) } // Set up the class field, but set it to null for now #connectedController = null connectedCallback() { // Make a new AbortController and extract the `signal` property const { signal } = (this.#connectedController = new AbortController()) // Pass the signal to addEventListener this.ownerDocument.addEventListener("keypress", this, { signal }) this.ownerDocument.addEventListener("mouseenter", this, { signal }) this.ownerDocument.addEventListener("mouseleave", this, { signal }) } disconnectedCallback() { // All cleanup happens with this one line this.#connectedController?.abort() // No need to do any of this: // this.ownerDocument.removeEventListener("keypress", this) // this.ownerDocument.removeEventListener("mouseenter", this) // this.ownerDocument.removeEventListener("mouseleave", this) } }
Using Cancellation Signals to cancel old requests
A common task that components might do is turn a user action into a network fetch. For example a search input might query the database every time a character is pressed. If the user types into the input fast enough, old network requests might stay in-flight, saturating the network and delaying newer requests from coming in, making the component feel sluggish. A good way to combat this is to cancel stale requests by using cancellation signals:
class SearchInputElement extends HTMLInputElement { static define(tag = "search-input") { customElements.define(tag, this, { extends: "input" }) } src = new URL("/search") connectedCallback() { this.addEventListener("input", this) } // Set up the class field, but set it to null for now #fetchController = null async handleEvent() { // Abort the old fetch, if the controller exists this.#fetchController?.abort() // Create a new one and extract the signal const { signal } = (this.#fetchController = new AbortContoller()) const src = new URL(this.src) src.searchParams.add("q", this.value) // Perform the fetch, make sure to pass it the signal so it can be aborted try { const res = await fetch(src, { signal }) } catch (error) { // An aborted network fetch will throw, so we should return early if (signal.aborted) return throw error } if (res.ok) { this.list.append(await res.text()) } } }