</> HTML5Advent
ENFRESDEITPT

// apis · Web Platform Advent #20

AbortController: cancelling fetches, timeouts and event listeners

One controller cancels a fetch, a timeout and a pile of event listeners. How AbortController and AbortSignal work, why a manual abort and a timeout throw different errors, and the cleanup trick most people miss.

A red emergency brake handle mounted on the wall inside a train carriage, labelled Notbremse

Starting an asynchronous operation is easy. Stopping one you no longer care about is the part most code skips, and it is where memory leaks, race conditions and stale results come from. AbortController is the browser's single answer to all of it.

One controller, one signal

MDN defines it plainly: the AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired. The shape is always the same. You create a controller, read its signal, and hand that signal to whatever you are starting.

The signal property returns an AbortSignal object instance, which can be used to communicate with, or to abort, an asynchronous operation. The controller is the thing you keep; the signal is the thing you give away.

const controller = new AbortController();

const res = await fetch('/api/articles', { signal: controller.signal });

// somewhere else, later:
controller.abort();

Note the direction. The code doing the work never decides to stop; it only agrees to listen. That separation is what lets one caller cancel many operations at once.

The two errors, and why it matters

This is the part worth reading carefully, because the same cancellation can throw two different things.

When you call abort() yourself, the fetch() promise rejects with a DOMException named AbortError. When the signal came from AbortSignal.timeout(), the fetch() promise rejects with a TimeoutError DOMException instead.

So the familiar check is incomplete:

try {
  const res = await fetch(url, { signal });
} catch (err) {
  if (err.name === 'AbortError') {
    // the user or our own code cancelled it
  } else if (err.name === 'TimeoutError') {
    // the deadline passed - a different situation entirely
  } else {
    throw err; // a real network or parsing failure
  }
}

Collapsing the two loses real information. A user navigating away is not a problem; a request that ran out of time probably is, and is worth reporting or retrying. Code that only looks for AbortError treats every timeout as a silent non-event.

Timeouts without the plumbing

The pattern most people still write pairs a controller with a setTimeout and a clearTimeout in a finally block. It works, and it is no longer necessary.

AbortSignal.timeout() returns an AbortSignal instance that will automatically abort after a specified time. No controller, no timer to clear, nothing to leak:

// Old: three moving parts
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timer);
}

// Now: one
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

Watch the error name when you make this swap. The old version threw AbortError; the new one throws TimeoutError, so any catch block you already had will need updating.

A worn red mushroom-head emergency stop button on a yellow housing, beside a black rocker switch, mounted on a machine
A worn emergency stop button on a machine, next to its ordinary switch. One control starts things one at a time; the other stops everything at once, which is exactly the division of labour between a signal and a controller.

The trick most people miss: event listeners

AbortSignal is not a fetch feature. addEventListener accepts a signal in its options, and when that signal aborts, the listener is removed for you. This quietly replaces the most tedious cleanup code in front-end work.

Compare removing listeners by hand, which requires keeping a reference to every function you passed:

// Before: you must keep every handler to remove it
function onScroll() { /* ... */ }
function onResize() { /* ... */ }
window.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);

function teardown() {
  window.removeEventListener('scroll', onScroll);
  window.removeEventListener('resize', onResize);
}

With a signal, the handlers can be inline again, and one call removes all of them:

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

window.addEventListener('scroll', () => { /* ... */ }, { signal });
window.addEventListener('resize', () => { /* ... */ }, { signal });
document.addEventListener('keydown', () => { /* ... */ }, { signal });

// one call removes all three
controller.abort();

The same controller can also be holding the signal for an in-flight fetch. Tearing down a view then becomes a single abort() that cancels the request and unbinds every listener together.

Combining signals, and reading state

AbortSignal.any() returns an AbortSignal that aborts when any of the given abort signals abort. That is how you get a deadline and a manual cancel on the same operation:

const controller = new AbortController();

const signal = AbortSignal.any([
  controller.signal,          // the user pressed Cancel
  AbortSignal.timeout(10000), // or ten seconds passed
]);

const res = await fetch(url, { signal });

A signal also tells you where it stands. aborted is a Boolean that indicates whether the request(s) the signal is communicating with is/are aborted, and reason is a JavaScript value providing the abort reason, once the signal has aborted. Passing your own reason to abort() is how you distinguish causes later:

controller.abort(new Error('User navigated away'));

// later
if (signal.aborted) console.log(signal.reason.message);

There is also an abort event, invoked when the asynchronous operations the signal is communicating with is/are aborted, which lets your own long-running code cooperate rather than only built-in APIs.

Making your own function abortable

Anything that takes a signal joins the same system. The pattern is to check aborted before starting and to listen for the abort event while running:

function wait(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);
    const id = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(id);
      reject(signal.reason);
    }, { once: true });
  });
}

Now your own helper can be cancelled by the same controller that cancels your fetches, which is the whole point of there being one mechanism rather than several.

Quick reference

GoalCall
Cancel manuallycontroller.abort(), throws AbortError
Add a deadlineAbortSignal.timeout(ms), throws TimeoutError
Either of the twoAbortSignal.any([a, b])
Auto-remove a listeneraddEventListener(type, fn, { signal })
Check statesignal.aborted
Find out whysignal.reason
React to cancellationthe abort event

AbortController is a small API that quietly solves a large category of cleanup problems. Hand one signal to your requests, your listeners and your own async helpers, then stop all of them with a single call. Just remember which error you are catching, because a manual abort and an expired deadline are not the same event.

For the request side of this, see our guide to the Fetch API in JavaScript.

Frequently asked questions

What is AbortController for?
MDN describes it as a controller object that allows you to abort one or more Web requests as and when desired. You create a controller, hand its signal to whatever you started, and call abort() when you want it stopped. It is not limited to fetch: anything that accepts an AbortSignal can be cancelled the same way.
What error does an aborted fetch throw?
It depends on how it was aborted, and this trips people up. When you call AbortController.abort() yourself, the fetch promise rejects with a DOMException named AbortError. When the signal came from AbortSignal.timeout(), the promise rejects with a TimeoutError DOMException instead. Code that only checks for AbortError will silently miss the timeout case.
How do I add a timeout to a fetch?
AbortSignal.timeout() returns an AbortSignal instance that will automatically abort after a specified time, so you pass AbortSignal.timeout(5000) as the signal and need no controller, no setTimeout and no clearTimeout. Remember it rejects with TimeoutError, not AbortError.
Can one controller cancel several things at once?
Yes, and that is the point of the name. A signal can be handed to any number of operations, so a single abort() call stops every fetch and removes every event listener that received it. It is the tidiest cleanup available when a view is torn down.
Can I combine a timeout with a manual cancel?
AbortSignal.any() returns an AbortSignal that aborts when any of the given abort signals abort. Pass it your controller signal and a timeout signal, and the operation stops on whichever happens first.