Skip to content
Node.js nd async 5 min read

Cancelling Async Work with AbortController

Long-running async work — a slow HTTP request, a file read, or a pending timer — often needs to be cancelled before it finishes. Maybe the user navigated away, a deadline elapsed, or another operation already produced the answer you needed. Node.js exposes the web-standard AbortController and AbortSignal primitives for exactly this: a single, composable cancellation mechanism that works across fetch, fs, timers, streams, and your own code. Because the API is standardized, the same patterns you learn here apply identically in browsers, Deno, and Bun.

How AbortController and AbortSignal work

An AbortController is a small object that owns one AbortSignal. You pass the signal into an async operation; you keep the controller. When you call controller.abort(), the signal flips to the aborted state, fires an abort event, and any operation watching it rejects (typically with an AbortError). The split is deliberate: the caller holds the controller and decides when to cancel, while the callee only sees a read-only signal it can observe but not trigger.

const controller = new AbortController();
const { signal } = controller;

console.log(signal.aborted); // false
controller.abort();
console.log(signal.aborted); // true
console.log(signal.reason);  // AbortError [or your custom reason]

Output:

false
true
DOMException [AbortError]: This operation was aborted

You can pass a custom reason to abort()controller.abort(new Error('user navigated away')) — and read it back from signal.reason. If you abort with no argument, Node fills in a default AbortError for you.

Cancelling fetch requests

The most common use case is aborting an in-flight HTTP request. Native fetch accepts a signal option, and the returned promise rejects when the signal aborts.

const controller = new AbortController();

// Cancel the request if it hasn't completed in 3 seconds.
const timer = setTimeout(() => controller.abort(), 3000);

try {
  const res = await fetch('https://api.example.com/slow', {
    signal: controller.signal,
  });
  const data = await res.json();
  console.log('Received', data);
} catch (err) {
  if (err.name === 'AbortError') {
    console.error('Request was cancelled');
  } else {
    throw err;
  }
} finally {
  clearTimeout(timer);
}

Output:

Request was cancelled

Always clear the timer in a finally block so a fast, successful response doesn’t leave a dangling timeout that aborts nothing.

AbortSignal.timeout() for deadlines

Wiring up a controller and a setTimeout just to enforce a deadline is boilerplate. AbortSignal.timeout(ms) builds a signal that aborts itself automatically after the given duration — no controller to manage and no timer to clear.

try {
  const res = await fetch('https://api.example.com/data', {
    signal: AbortSignal.timeout(5000),
  });
  console.log(await res.json());
} catch (err) {
  if (err.name === 'TimeoutError') {
    console.error('Request timed out after 5s');
  }
}

A signal from AbortSignal.timeout() aborts with a TimeoutError, not an AbortError. Check err.name === 'TimeoutError' if you want to distinguish a deadline from a manual cancellation.

Combining signals with AbortSignal.any()

Sometimes an operation should cancel if any of several conditions occurs — say, a user-triggered cancel or a hard timeout. AbortSignal.any([...signals]) returns a single signal that aborts as soon as the first of its inputs aborts, adopting that signal’s reason.

const userCancel = new AbortController();

const combined = AbortSignal.any([
  userCancel.signal,
  AbortSignal.timeout(10_000),
]);

const res = await fetch('https://api.example.com/report', {
  signal: combined,
});

// Elsewhere: userCancel.abort() cancels immediately,
// or the 10s timeout fires first — whichever comes first wins.

This composes cleanly and avoids manually subscribing to multiple signals.

Aborting fs operations and timers

Cancellation isn’t limited to HTTP. Many core modules accept a signal option.

APIHow to pass the signalAborts with
fetch(url, { signal })optionAbortError
fs.readFile(path, { signal })optionAbortError
fs.writeFile(path, data, { signal })optionAbortError
setTimeout(fn, ms, opts, { signal }) (from node:timers/promises)optionAbortError
stream.pipeline(..., { signal })optionAbortError
import { readFile } from 'node:fs/promises';
import { setTimeout as delay } from 'node:timers/promises';

const controller = new AbortController();
setTimeout(() => controller.abort(), 100); // abort soon

try {
  // A promise-based timer that can be cancelled.
  await delay(5000, undefined, { signal: controller.signal });
  console.log('waited the full 5s'); // never runs
} catch (err) {
  if (err.name === 'AbortError') console.error('Delay cancelled early');
}

// fs example
const ctrl = new AbortController();
const reading = readFile('./big.log', { signal: ctrl.signal });
ctrl.abort();
await reading.catch((e) => console.error('Read aborted:', e.name));

Output:

Delay cancelled early
Read aborted: AbortError

In CommonJS, swap the imports for const { readFile } = require('node:fs/promises') and const { setTimeout: delay } = require('node:timers/promises'). The signal APIs themselves are global in Node 18+.

Listening for the abort event

When you write your own cancellable function, observe the signal directly. You can subscribe with addEventListener('abort', ...), check signal.aborted up front, or call signal.throwIfAborted() to bail immediately if cancellation already happened.

function waitForResource(signal) {
  return new Promise((resolve, reject) => {
    signal.throwIfAborted(); // reject right away if already aborted

    const id = setInterval(() => {
      if (resourceReady()) {
        clearInterval(id);
        resolve('ready');
      }
    }, 250);

    signal.addEventListener('abort', () => {
      clearInterval(id);
      reject(signal.reason);
    }, { once: true });
  });
}

Use { once: true } so the listener auto-removes after firing, and always clean up timers, sockets, or watchers inside the handler to avoid leaks.

Best Practices

  • Hold the AbortController in the caller and pass only signal to the operation you want to control.
  • Always check err.name ('AbortError' vs 'TimeoutError') to handle cancellation distinctly from real failures.
  • Prefer AbortSignal.timeout(ms) over a manual controller plus setTimeout when you only need a deadline.
  • Use AbortSignal.any([...]) to merge user-cancel and timeout signals instead of subscribing to each one.
  • Clear timers and remove abort listeners in finally or with { once: true } to prevent leaks.
  • Call signal.throwIfAborted() at the start of long operations to fail fast on an already-aborted signal.
  • Reuse a single signal across related operations so one abort() tears down the whole group at once.
Last updated June 14, 2026
Was this helpful?