search
LoginSignup
2

More than 1 year has passed since last update.

posted at

updated at

Everything about Cancellation of Promises

[ 日本語版 ]

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. :rocket:

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! :scream:

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 :smiley_cat:.

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. :wink: 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! :thinking: .aborted can be true. So, what happens here:

const control = new AbortController()
control.abort()
getList('users', control.signal)

:scream:

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! :dark_sunglasses:

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. :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! :muscle:

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! :thumbsup:

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! :wink:

$ 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 :tada:... 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! :smile_cat:

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! :heart:

:wave: See you next year!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
2