Home JS

AbortController and AbortSignal

AbortController is the platform's standard cancellation primitive. AbortSignal flows through fetch, addEventListener, streams, and most async platform APIs added since 2019.

What the pair does

AbortController is a producer of cancellation events. AbortSignal is the consumer side. Code that wants to be cancellable accepts a signal:

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

const res = await fetch('/api/users', { signal });

// Elsewhere
controller.abort(new Error('user cancelled'));

abort(reason?) flips the signal’s aborted flag, fires its abort event, and notifies every observer that’s listening.

The pair is defined in DOM Living Standard (§3.2 Abort), not in a separate spec — reflecting that cancellation is a DOM primitive, not a Fetch primitive.

Where it works

Since 2019, signal acceptance has spread across the platform:

  • fetch(url, { signal }) — the original integration (2019).
  • addEventListener(type, fn, { signal }) — auto-removes the listener when the signal aborts (Chromium 90, 2021; cross-engine by 2022).
  • ReadableStream.cancel(reason) — accepts a signal indirectly via the consumer.
  • EventTarget.dispatchEvent does not — events are synchronous.
  • setTimeout(fn, ms, { signal }) — proposal, not yet shipped.
  • crypto.subtle.digest({ name: 'SHA-256' }, data, { signal }) — proposed, partial.

About 90% of cancellable async APIs added between 2019 and 2024 accept a signal.

Composing signals

AbortSignal.any([s1, s2, ...]) (cross-engine 2024) returns a signal that aborts when any input aborts. Useful for combining a user-cancel signal with a timeout:

const userCancel = new AbortController();
const timeout = AbortSignal.timeout(5000);
const combined = AbortSignal.any([userCancel.signal, timeout]);

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

AbortSignal.timeout(ms) (cross-engine 2023) returns a signal that aborts after the given milliseconds with a TimeoutError DOMException.

AbortSignal.abort(reason?) is the static factory for an already-aborted signal — useful for tests or for short-circuiting a pre-cancelled flow.

Reading the abort reason

After abort, signal.reason holds the value passed to abort(reason). Code that wants useful error messages should pass concrete Error instances:

controller.abort(new DOMException('User clicked cancel', 'AbortError'));

Catching code should distinguish a deliberate cancel from a network failure:

try {
  await fetch(url, { signal });
} catch (err) {
  if (err.name === 'AbortError') return;   // expected
  throw err;                                // unexpected
}

Fetch wraps the abort reason in an AbortError exception by spec; comparing err.name === 'AbortError' is the portable pattern.

addEventListener integration

The signal option on addEventListener removes the boilerplate “remember the listener and call removeEventListener”:

const controller = new AbortController();

button.addEventListener('click', onClick, { signal: controller.signal });
window.addEventListener('resize', onResize, { signal: controller.signal });

// Later — one call removes both
controller.abort();

In a custom-element teardown, this pattern reduces about 30% of typical event-binding code.

Common patterns

Per-request cancellation

let inflight;
async function search(q) {
  inflight?.abort();
  inflight = new AbortController();
  try {
    const res = await fetch(`/search?q=${encodeURIComponent(q)}`,
      { signal: inflight.signal });
    return await res.json();
  } catch (e) {
    if (e.name !== 'AbortError') throw e;
  }
}

Each new search aborts the previous request; the response of an older query never overrides a newer one.

Component lifetime

class WikiSearch extends HTMLElement {
  #ac = new AbortController();
  connectedCallback() {
    this.addEventListener('input', this.#onInput, { signal: this.#ac.signal });
    document.addEventListener('keydown', this.#onKey, { signal: this.#ac.signal });
  }
  disconnectedCallback() { this.#ac.abort(); }
}

The pattern survives multiple connect/disconnect cycles by reassigning #ac on connect.

Common pitfalls

  • Calling abort() twice. Idempotent; safe but no-op.
  • Listening for abort event via signal.addEventListener(“abort”, fn) without setting { once: true } — the listener leaks.
  • Reading signal.reason before abort. Returns undefined.
  • Checking signal.aborted and proceeding asynchronously. Race condition: signal might abort between the check and the next microtask. Always pass the signal into the cancellable API rather than checking and proceeding.
  • No handler for AbortError rejection. Logs as an unhandled-rejection warning even when cancellation is expected.

Cross-engine support

Core AbortController/AbortSignal reached interop in 2019. AbortSignal.timeout() reached interop in 2023. AbortSignal.any() reached interop in 2024. The addEventListener signal option reached interop in 2022.

Web Platform Tests for AbortController report about 99% pass-rate across Chromium (Blink), WebKit, and Gecko as of the 2024 Interop dashboard.

Further reading