Skip to content

Using AbortSignal in Node.js

The AbortController and AbortSignal APIs are quickly becoming the standard mechanism for cancelling asynchronous operations in the Node.js core API.

The AbortController and AbortSignal APIs are quickly becoming the standard mechanism for cancelling asynchronous operations in the Node.js core API.

If you search how to use the Promise.race() API, you'll come across quite a few variations of the following:

js
const timeout = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('timed out')), 10000);
});

await Promise.race([
  someLongRunningTask(),
  timeout
]);

The intent here is straightforward: Start a potentially long-running task but trigger a timeout if that task takes too long to complete. This is generally a good idea, but there are quite a few problems with this common example.

First, although the promise returned by Promise.race() will be fulfilled as soon as the first of the given promises is settled, the other promises are not cancelled and will keep on running. Although the timeout timer did fire, the long-running task is never actually interrupted and stopped.

Second, what happens to the timeout promise if the long-running task completes before the timeout is triggered? The answer is simple: The timer keeps running, and the promise will end up rejecting, still with an unhandled rejection — unnecessarily risking performance issues and possible memory leaks in your application.

To correctly handle this pattern, we need a reliable mechanism for signalling across the two promises, canceling either the timer or the long-running task as appropriate and ensuring that once the timeout is triggered all resources are cleaned up as quickly as possible. Fortunately, Web Platform APIs provide a standard mechanism for this kind of signalling — the AbortController and AbortSignal APIs.

In Node.js, a better way if implementing a Promise.race -based timeout would be:

js
import { setTimeout } from 'timers/promises';

const cancelTimeout = new AbortController();
const cancelTask = new AbortController();

async function timeout() {
  try {
    await setTimeout(10000, undefined, { signal: cancelTimeout.signal });
    cancelTask.abort();
  } catch {
    // Ignore rejections here
  }
}

async function task() {
  try {
    await someLongRunningTask({ signal: cancelTask.signal });
  } finally {
    cancelTimeout.abort();
  }
}

await Promise.race([ timeout(), task() ]);

As with the previous example, two promises are created. However, when each completes, it uses the AbortController and AbortSignal APIs to explicitly signal to the other that it should stop. As long as the code in those is written to support the AbortSignal API, everything just works.

For instance, in the example we make use of the recently added awaitable timers API in Node.js. These are variants of the setTimeout() and setInterval() that return promises.

text
import { setTimeout } from 'timers/promises';

// Awaits a promise that fulfills after 10 seconds
await setTimeout(10000);

The awaitable timer API supports the ability to pass in an AbortSignal instance. When the AbortSignal is triggered, the timer is cleared and the promise immediately rejects with an AbortError .

Support for AbortController and AbortSignal is being rolled out across the Node.js core API and can now be found in most of the major subsystems. Before we explore where the API can be used, let's find out a bit more about the API itself.

All about AbortController and AbortSignal

The AbortController interface is simple. It exposes just two important things — a signal property whose value is an AbortSignal and an abort() method that triggers that AbortSignal .

The AbortSignal itself is really nothing more than an EventTarget with a single type of event that it emits — the 'abort' event. One additional boolean aborted property is true if the AbortSignal has already been triggered:

js
const ac = new AbortController();

const signal = ac.signal;

signal.addEventListener('abort', () => {
  console.log('Abort!');
}, { once: true });

ac.abort();

The AbortSignal can only be triggered once.

Notice that when I added the event listener in the example above, I included the { once: true } option. This ensures that the event listener is removed from the AbortSignal as soon as the abort event is triggered, preventing a possible memory leak.

Note that it's even possible to pass an AbortSignal onto the addEventListener() itself, causing the event listener to be removed if that AbortSignal is triggered.

js
const ac = new AbortController();
const signal = ac.signal;

eventTarget.addEventListener('some-event', () => { /*...*/ }, { signal });

This starts to get a bit complicated too, but it's important for preventing memory leaks when coordinating the cancellation of multiple complex tasks. We'll see an example of how this all comes together next.

Implementing API support for AbortSignal

The AbortController API is used to signal that an operation should be cancelled. The AbortSignal API is used to receive notification of those signals. They always come in pairs.

The idiomatic way of enabling a function (like the someLongRunningTask() function in our examples above) to support this pattern is to pass a reference to the AbortSignal in as part of an options object:

js
async function someLongRunningTask(options = {}) {
  const { signal } = { ...options };
  // ...
}

Within this function, you should immediately check to see if the signal has already been triggered and, if it has, immediately abort the operation.

js
async function someLongRunningTask(options = {}) {
  const { signal } = { ...options };
  if (signal?.aborted === true)
    throw new Error('Operation canceled');
  // ...
}

Next, it's important to set up the handling of the 'abort' event before starting to process the task:

js
async function someLongRunningTask(options = {}) {
  const { signal } = { ...options };
  if (signal.aborted === true)
    throw new Error('Operation canceled');
  
  const taskDone = new AbortController();
  signal.addEventListener('abort', () => {
    // logic necessary to cancel the async task
  }, {
    once: true,
    signal: taskDone.signal
  });
  try {
    // ... do the async work
    if (signal.aborted)
      throw new Error('Operation canceled');
    // ... do more async work
  } finally {
    // Remove the abort event listener to avoid
    // leaking memory.
    taskDone.abort();
  }
}

Notice here that we are creating an additional AbortController instance whose signal is passed in with the event listener. After we've completed the asynchronous task, we trigger that AbortController to let the AbortSignal know that the event handler can be removed. We want to make sure that the listener is cleaned up even if the async task fails, so we wrap the call to taskDone.abort() in a finally block.

It is also important to check if the signal has been triggered between various async tasks the method may be performing. This is important to catch cases where the event may not yet have had an opportunity to be emitted but the operation should still be interrupted.

Using AbortController and AbortSignal

The AbortController and AbortSignal APIs are quickly becoming the standard mechanism for canceling asynchronous operations in the Node.js core API. For example, as of node.js 15.3.0, it is possible to cancel an HTTP request using the API:

js
const http = require('http');

const ac = new AbortController();
const signal = ac.signal;

http.request('https://example.org', { signal }, (res) => { /** … **/ });

// Cancel the request!
ac.abort();

Consult the Node.js documentation for more details on exactly which APIs support AbortSignal . More are being added all the time and support may vary across different Node.js major versions.

Insight, imagination and expertly engineered solutions to accelerate and sustain progress.

Contact