Everything about Cancellation of Promises

[ [日本語版 ](https://qiita.com/martinheidegger/items/6e8275d2de88174bc7e6) ]

In this **thorough** post about JavaScript and Node.js you can learn about the history of cancellation of [Promises][Promise]. Why it is relevant to Node.js and what you should be aware of when you try to use it with the [async/await][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][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()][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][bluebird] 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()][bluebird-cancel] method, even though it hasn't become a standard.

It took another 4 years until browsers made the DOM class [AbortController][AbortController] widely available as an effort to cancel [fetch()][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][AbortController-node] 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](https://www.npmjs.com/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][prop-cancellation].

Another interesting approach has been presented with [CAF][CAF] which uses `generator` functions instead of `async/await` to handle async workflows.

[AbortController-node]: https://github.com/nodejs/node/commit/a8b26d72c5
[gen-fn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator
[prop-cancellation]: https://github.com/tc39/proposal-cancellation/
[CAF]: https://github.com/getify/CAF

## 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:

```javascript
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`?

```javascript
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.

```javascript
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()][readfile] has a new option: `signal`, and there is the experimental [require('timers/promises')][timers-promises] API that also allows `setTimeout(200, { signal })` _(keep your eyes open for other examples!)_.

Let's see how to use them:

```javascript
// 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?

```javascript
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][.aborted]:

```javascript
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_face: `.aborted` **can** be `true`. So, what happens here:

```javascript
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:

```javascript
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][abort-event].

```javascript
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:

```javascript
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:

```javascript
async function myApi ({ signal } = {}) {}
```

This makes signal part of an optional `options` property that may very well be `null`.

If you use [TypeScript][typescript] - even just the [`.d.ts` files][dts] - you might want to give `signal` the signature provided by `abort-controller`:

```typescript
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:

```javascript
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:

```javascript
// 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`][aborterr-nodejs] and [in the browser][aborterr-browser] (thanks [@rithmety](https://qiita.com/rithmety)). This way we will be future-proof! :dark_sunglasses: 

[aborterr-browser]: https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError
[aborterr-nodejs]: https://github.com/nodejs/node/blob/master/lib/internal/errors.js#L735-L741

## 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()`:

```javascript
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:

```javascript
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()`][bind] to make that simpler:

[bind]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind

```javascript
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:

```javascript
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: 

```javascript
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()][promise-race] to abort the slower of two processes:

```javascript
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?


```javascript
function startTwoThings ({ signal } = {}) {
  const controller = new AbortController()
  // ...
}
```

Now, we have two signals?! This needs some creative problem-solving...

```javascript
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:

```javascript
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:

```shell
$ npm install @consento/promise --save
```

```javascript
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!

```javascript
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:

```javascript
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:

```javascript
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][promises-new-issue] or by adding a comment! :heart:

:wave: See you next year!

[dts]: https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html
[typescript]: https://www.typescriptlang.org/
[promises-new-issue]: https://github.com/consento-org/promise/issues/new/choose
[timers-promises]: https://nodejs.org/api/timers.html#timers_timerspromises_settimeout_delay_value_options
[promise-race]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race
[abort-event]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort_event
[.aborted]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/aborted
[readfile]: https://nodejs.org/api/fs.html#fs_fs_readfile_path_options_callback
[prop-cancel]: https://github.com/tc39/proposal-cancellation
[async-await]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
[Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
[bluebird]: http://bluebirdjs.com/
[bluebird-cancel]: http://bluebirdjs.com/docs/api/cancel.html
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
[AbortController]: https://developer.mozilla.org/en-US/docs/Web/API/AbortController