以下は英語版です。日本語版はまだ準備中です。申し訳ありません。今日中に公開するつもりです。
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.
But 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 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 < 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 lets look at a concrete, JavaScript example code to see where the problem is:
asyncfunctiongetPage(list,page){constresponse=awaitfetch(`/data?list=${list}&page=${page}`)returnawaitresponse.json()}asyncfunctiongetList(list){letpage=0,hasMore=false,data=[]do{constloaded=awaitgetPage(list,page)data=data.concat(loaded.data)hasMore=page!==loaded.lastPagepage++}while(hasMore)returndata}constpromiseA=getList('users')promiseA.then(entries=>{/* do something */},error=>{console.error(error)})
This example loads a user-list on the server. But what happens if the user decideds to switch the UI from users
to messages
?
constpromiseB=getList('messages')promiseB.then(entries=>{/*...*/})
Now promiseA
is started even though promiseB
may not have finished yet.
In an effort to understand the challenge, lets try to stop promiseA
without any new API.
letcurrentRequest=0asyncfunctiongetList(list){letpage=0,hasMore=false,data=[]constrequest=++currentRequestdo{console.log(`load ${list}/${page}`)if(request!==currentRequest){thrownewError('new request sent, stopping this one')}constloaded=awaitgetPage(list,page)data=data.concat(loaded.data)hasMore=page<loaded.lastPageconsole.log({hasMore,page,last:loaded.lastPage})page++}while(hasMore)returndata}
Now, any list that we fetch will automatically stop the previous request with an error.
This simple example is highlighting a few issues that kept the :
- 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 v15const{setTimeout}=require('timers/promises')constcontrol=newAbortController()constpromise=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?
asyncfunctiongetPage(...,signal){constresponse=awaitfetch(...,{signal})returnawaitresponse.json()}asyncfunctiongetList(...,signal){// ...do{constloaded=awaitgetPage(...,signal)// ...}while(hasMore)returndata}constcontrol=newAbortController()constpromiseA=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
:
asyncfunctiongetPage(...,signal){// ...if(signal?.aborted){thrownewError('aborted')}returnawaitresponse.json()}
Now we can be sure that no unnecessary .json
call is triggered, neat.
But Wait!.aborted
can be true
. So, what happens here:
constcontrol=newAbortController()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:
asyncfunctiongetList(...,signal){if(signal?.aborted){thrownewError('aborted')}// ..}
I had to learn this the hard way when I tried to use the abort
event.
functionwaitForAbort(signal){returnnewPromise(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:
functionwaitForAbort(signal){returnnewPromise(resolve=>{if(signal.aborted)resolve()elsesignal.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:
asyncfunctionmyApi({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'interfaceMyApiOptions{signal?:AbortSignal}declarefunctionmyApi(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:
asyncfunctionmyApi(opts?:MyApiOptions):void{if(opts?.signal?.aborted){throwObject.assign(newError('aborted'),{code:'ABORT_ERR'})}thrownewError('other error')}
This way we can check in an catch
-block easily what error occured:
// Assuming top-level awaittry{awaitmyApi()}catch(error){if(error.code==='ABORT_ERR'){// do nothing}Handleerror}
We use the code ABORT_ERR
because it is the same .code
also used by Node.js
. 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()
:
classAbortErrorextendsError{constructor(){super('The operation was aborted.')this.code="ABORT_ERR"this.name='AbortError'}}if(signal?.aborted){thrownewAbortError()}
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:
functionbubbleAbort(signal){if(signal?.aborted)thrownewAbortError()}asyncfunctiongetPage(list,page,{signal}={}){bubbleAbort(signal)constresponse=awaitfetch(...,{signal})bubbleAbort(signal)returnawaitresponse.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:
asyncfunctiongetPage(list,page,{signal}={}){constcheckpoint=bubbleAbort.bind(null,signal)checkpoint()constresponse=awaitfetch(...,{signal})checkpoint()returnawaitresponse.json()}
This looks better. In a further step, we extract this block into a function:
functioncheckpoint(signal){bubbleAbort(signal)return()=>bubbleAbort(signal)}asyncfunctiongetPage(list,page,{signal}={}){constcp=checkpoint(signal)constresponse=awaitfetch(...,{signal})cp()returnawaitresponse.json()}
Still a bit noisy. We can do better!
functioncheckpoint(signal){bubbleAbort(signal)returnnext=>{bubbleAbort(signal)returnnext?()}}asyncfunctiongetPage(list,page,{signal}={}){constcp=checkpoint(signal)constresponse=awaitfetch(...,{signal})returnawaitcp(()=>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')functionresolveRandom(data,opts){returnsetTimeout(Math.random()*400,data,opts)}functionstartTwoThings(){constcontroller=newAbortController()constopts={signal:controller.signal}returnPromise.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?
functionstartTwoThings({signal}={}){constcontroller=newAbortController()// ...}
Now, we have two signals?! This needs some creative problem-solving...
functioncomposeAbort(signal){bubbleAbort(signal)// signal could be aboertedconstcontroller=newAbortController()letaborted=falseconstabort=()=>{if(aborted)returnaborted=truesignal?.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 and a parent process.
Using this new composeAbort
helper we can go back to our initial task:
functionstartTwoThings({signal}={}){const{abort,signal}=composeAbort(signal)constopts={signal}returnPromise.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')functionstartTwoThings(opts){returnraceWithSignal(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')awaitwrapTimeout(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')constcontroller=newAbortController()constp=cleanupPromise((resolve,reject,signal)=>{// Like regular promise but with added `signal` propertyreturnfunctioncleanup(){// 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 cancelable APIs. Maybe you do too?
If you have any questions, don't hesitate to let me know in our issue tracker!
See you next year!