[ 日本語版 ]
In this thorough post about JavaScript and Node.js you can learn about the history of cancellation of Promises. Why it is relevant to Node.js and what you should be aware of when you try to use it with the async/await API.
This post is for you if you have a good insight into the JavaScript
Promise API and some experience with Node.js
.
History
Ever since the Promise API was introduced to browsers in 2014, people were looking into what else can be done with Promises. The first relevant API that made it to browsers was fetch() for HTTP
requests.
The thing with HTTP
requests is that they cost server resources and if you have a lot of requests that are sent to a server it costs money. This made particular Cancellation
(which sounds better than Aborting
) an important detail. With Cancellation, it would be possible to prevent further execution of a request that is not relevant anymore: resulting in faster clients and servers.
The bluebird.js library offered even before Promise was shipped with browsers a very fast Promise implementation. And even though it changed in v3
and needs to be enabled, it does offer to this day a .cancel() method, even though it hasn't become a standard.
It took another 4 years until browsers made the DOM class AbortController widely available as an effort to cancel fetch() requests. Pretty much at the same time as async/await
became available! (Sidenote: The DOM is not part of the JavaScript specification)
For a complete picture: Promises appeared in 2015 in Node.js with v0.12. async/await
became stable with v8 in 2017 but AbortController
only became available with v15 in 2020, which means it is not stable at the time of writing!
If you want to use AbortController
or AbortSignal
in Node.js before v15, the npm package abort-controller is available since 2018.
There have been other efforts surrounding this topic as well. Most prominently there is a new proposed tc39 spec (the group of people who develop JavaScript) that focuses on cancellation: proposal-cancellation.
Another interesting approach has been presented with CAF which uses generator
functions instead of async/await
to handle async workflows.
Problem overview
As the introduction to proposal-cancellation
states there many processes that can profit from cancellation:
- Asynchronous functions or iterators
- Fetching remote resources (HTTP, I/O, etc.)
- Interacting with background tasks (Web Workers, forked processes, etc.)
- Long-running operations (animations, etc.)
- Synchronous observation (e.g. in a game loop)
- Asynchronous observation (e.g. aborting an XMLHttpRequest, stopping an animation)
But this is all very theoretical. Lets look at a concrete JavaScript example code to see where the problem is:
async function getPage (list, page) {
const response = await fetch(`/data?list=${list}&page=${page}`)
return await response.json()
}
async function getList (list) {
let page = 0, hasMore = false, data = []
do {
const loaded = await getPage(list, page)
data = data.concat(loaded.data)
hasMore = page !== loaded.lastPage
page++
} while (hasMore)
return data
}
const promiseA = getList('users')
promiseA.then(
entries => { /* do something */ },
error => { console.error(error) }
)
This example loads a user-list from a server. But what happens if the user decides to switch the UI from users
to messages
?
const promiseB = getList('messages')
promiseB.then(
entries => { /*...*/ }
)
Now promiseA
is started even though promiseB
may not have finished yet. Both requests run in parallel, we are not sure which one will resolve first and the server needs to process both requests.
To understand how Cancellation works, lets try to stop promiseA
without any special API.
let currentRequest = 0
async function getList (list) {
let page = 0, hasMore = false, data = []
const request = ++currentRequest
do {
console.log(`load ${list}/${page}`)
if (request !== currentRequest) {
throw new Error('new request sent, stopping this one')
}
const loaded = await getPage(list, page)
data = data.concat(loaded.data)
hasMore = page < loaded.lastPage
console.log({ hasMore, page, last: loaded.lastPage })
page++
} while (hasMore)
return data
}
Now, any list that we fetch will automatically stop the previous request with an error.
That's it. All the rest of this article is about how to do this the best way .
This simple example is highlighting a few issues that are topics in all implementation:
-
Who is in control? In this case, it's stopped internally.
AbortSignal
is externally but other concepts may share the control. -
This is a "best-effort". Not everything can be stopped.
HTTP
requests go out and in our example, we don't even stop fetch. What we can do is prevent future action as best as possible. -
It can resolve after
abort()
. In this example, we only stop before we send out the next request. This means that, even though we aborted the request, it could still resolve successfully!
Using the AbortSignal
If you look closely at the Node.js v15 API, you may notice that fs.readFile() has a new option: signal
, and there is the experimental require('timers/promises') API that also allows setTimeout(200, { signal })
(keep your eyes open for other examples!).
Let's see how to use them:
// Assumes Node.js v15
const { setTimeout } = require('timers/promises')
const control = new AbortController()
const promise = setTimeout(
() => console.log('hello world'),
500,
{ signal: control.signal }
)
control.abort()
This will cause the following error:
Uncaught:
DOMException [AbortError]: The operation was aborted
Cool. But how about using the same API with the initial example?
async function getPage (..., signal) {
const response = await fetch(..., { signal })
return await response.json()
}
async function getList (..., signal) {
// ...
do {
const loaded = await getPage(..., signal)
// ...
} while (hasMore)
return data
}
const control = new AbortController()
const promiseA = getList('users', control.signal)
control.abort()
Now getList(..., signal)
will pass the signal to getPage(..., signal)
passing it on to fetch
, but what should we do with await response.json()
? It doesn't have signal support but we can do this using .aborted:
async function getPage (..., signal) {
// ...
if (signal?.aborted) {
throw new Error('aborted')
}
return await response.json()
}
Now we can be sure that no unnecessary .json
call is triggered, neat.
But Wait! .aborted
can be true
. So, what happens here:
const control = new AbortController()
control.abort()
getList('users', control.signal)
The signal can be aborted at the start of a function. This means that a function that accepts a signal needs to check it at the beginning:
async function getList(..., signal) {
if (signal?.aborted) {
throw new Error('aborted')
}
// ..
}
I had to learn this the hard way when I tried to use the abort
event.
function waitForAbort(signal) {
return new Promise(resolve => {
signal.addEventListener('abort', resolve)
})
}
This never resolved when the signal
was passed in aborted: This was hard to debug and I had to change it to:
function waitForAbort(signal) {
return new Promise(resolve => {
if (signal.aborted) resolve()
else signal.addEventListener('abort', resolve)
})
}
To summarize what we have learned here:
-
We know who is in control: The piece of code that holds the
control
instance! - This is a "best-effort": we don't stop code that is finished.
- A signal may be aborted: we need to abort early.
Good API
The fetch()
-API wisely made signal
optional. It will still work if you don't pass in a signal. You also may want to make signal
optional. The API recommendation that I have currently:
async function myApi ({ signal } = {}) {}
This makes signal part of an optional options
property that may very well be null
.
If you use TypeScript - even just the .d.ts
files - you might want to give signal
the signature provided by abort-controller
:
import { AbortSignal } from 'abort-controller'
interface MyApiOptions {
signal?: AbortSignal
}
declare function myApi (opts?: MyApiOptions): void
But what about the error case? How can we differentiate a promise from being aborted to a promise with an error? The safest way is to
add a .code
property to the Error
instance:
async function myApi (opts?: MyApiOptions): void {
if (opts?.signal?.aborted) {
throw Object.assign(
new Error('aborted'),
{ code: 'ABORT_ERR', name: 'AbortError' }
)
}
throw new Error('other error')
}
This way we can check in an catch
-block easily what error occured:
// Assuming top-level await
try {
await myApi()
} catch (error) {
if (error.name === 'AbortError') {
// do nothing
}
Handle error
}
We use .name === 'AbortError'
because it is the same .name
also used by Node.js
and in the browser (thanks @rithmety). This way we will be future-proof!
Less noise, fewer mistakes
Adding if-causes and complex error statements to code is a sure way to make mistakes.
Let's refactor the code together to prevent those mistakes!
First step: Extend Error
instead of Object.assign()
:
class AbortError extends Error {
constructor() {
super('The operation was aborted.')
this.code = "ABORT_ERR"
this.name = 'AbortError'
}
}
if (signal?.aborted) {
throw new AbortError()
}
This reduces the amount to type and makes sure we have the correct error. Our next step: extract the whole if
block into a function:
function bubbleAbort(signal) {
if (signal?.aborted) throw new AbortError()
}
async function getPage(list, page, { signal } = {}) {
bubbleAbort(signal)
const response = await fetch(..., { signal })
bubbleAbort(signal)
return await response.json()
}
Side-note: I like the name bubbleAbort
- it sounds like a bubble bath.
If we have to write bubbleAbort
often, we do get quite a bit of duplication. Let's use bind()
to make that simpler:
async function getPage(list, page, { signal } = {}) {
const checkpoint = bubbleAbort.bind(null, signal)
checkpoint()
const response = await fetch(..., { signal })
checkpoint()
return await response.json()
}
This looks better. In a further step, we extract this block into a function:
function checkpoint (signal) {
bubbleAbort(signal)
return () => bubbleAbort(signal)
}
async function getPage(list, page, { signal } = {}) {
const cp = checkpoint(signal)
const response = await fetch(..., { signal })
cp()
return await response.json()
}
Still a bit noisy. We can do better!
function checkpoint (signal) {
bubbleAbort(signal)
return next => {
bubbleAbort(signal)
return next?()
}
}
async function getPage(list, page, { signal } = {}) {
const cp = checkpoint(signal)
const response = await fetch(..., { signal })
return await cp(() => response.json())
}
That seems to be as good as it gets. Well done!
In case you need an AbortSignal
Let's look a bit at another use case and say we use Promise.race() to abort the slower of two processes:
const { setTimeout } = require('timers/promises')
function resolveRandom (data, opts) {
return setTimeout(Math.random() * 400, data, opts)
}
function startTwoThings () {
const controller = new AbortController()
const opts = { signal: controller.signal }
return Promise.race([
resolveRandom('A', opts),
resolveRandom('B', opts),
]).finally(
() => controller.abort()
)
}
This will abort
the slower one. So far so good! But, what happens if we still want to give control to the caller?
function startTwoThings ({ signal } = {}) {
const controller = new AbortController()
// ...
}
Now, we have two signals?! This needs some creative problem-solving...
function composeAbort (signal) {
bubbleAbort(signal) // signal could be aboerted
const controller = new AbortController()
let aborted = false
const abort = () => {
if (aborted) return
aborted = true
signal?.removeEventListener('abort', abort)
controller.abort()
}
signal?.addEventListener('abort', abort)
return { abort, signal: controller.signal }
}
This is powerful! Now we get a controller-like structure that can be aborted by itself or via a parent process.
Using this new composeAbort
helper we can go back to our initial task:
function startTwoThings ({ signal } = {}) {
const { abort, signal } = composeAbort(signal)
const opts = { signal }
return Promise.race([
resolveRandom('A', opts),
resolveRandom('B', opts),
]).finally(abort)
}
Great, we have a working race function! this will surely come in handy!
Published on NPM
Wow! We got quite far, let's put AbortError
, bubbleAbort
, checkpoint
and composeAbort
together into an npm package and publish it...
Ooops, that's already done!
$ npm install @consento/promise --save
const { AbortError, bubbleAbort, checkpoint } = require('@consento/promise')
Recently, we extracted these functions from our code into a library published it with docs ... and, it has a bit more to offer!
One thing is the raceWithSignal
function: It makes that previous example even easier!
const { raceWithSignal } = require('@consento/promise')
function startTwoThings (opts) {
return raceWithSignal(signal => [
resolveRandom('A', { signal }),
resolveRandom('B', { signal }),
], opts?.signal)
}
It also comes with wrapTimeout
that takes care of a common problem: to cancel a promise when a timeout is reached:
const { wrapTimeout } = require('@consento/promise')
await wrapTimeout(
signal => startTwoThings({ signal }),
{ timeout: 100 }
)
And to finish things for today we have cleanupPromise
. This is an alternative to new Promise
. It supports cancellation
, timeouts
, and a cleanup method:
const { cleanupPromise } = require('@consento/promise')
const controller = new AbortController()
const p = cleanupPromise(
(resolve, reject, signal) => {
// Like regular promise but with added `signal` property
return function cleanup () {
// Cleanup operation is called in place of finally
}
},
{ timeout: 500, signal: controller.signal }
)
Final Words
That is all I can tell you about Cancellation at this time. I hope you enjoyed it!
Are you as excited as I am about the advances that Node.js made?
With these few tricks, I feel comfortable creating cancellable APIs. Maybe you do too?
If you have any questions, don't hesitate to let me know in our issue tracker or by adding a comment!
See you next year!