AbortController

TL;DR / AbortController provides a signal-based cancellation mechanism for fetch requests, event listeners, DOM operations, and any async workflow that accepts an AbortSignal.

How It Works

 ┌─────────────────┐           ┌─────────┐
 │ AbortController │──────────→│ fetch() │ <- AbortError
 └─────────────────┘           └─────────┘
          │
          │
          │
          ↓
 ┌─────────────────┐           ┌────────────────────┐
 │     .signal     │──────────→│ addEventListener() │ <- removed
 └─────────────────┘           └────────────────────┘
          │
          └──────────────┐
     .abort() called     │
                         │
                         │     ┌────────────┐
                         └────→│ async task │ <- reason thrown
                               └────────────┘

Edit diagram

AbortController is a standard DOM API that creates an AbortSignal object usable as a cancellation token across multiple consumers. The controller is the write side (calling .abort()), and the signal is the read side (observing cancellation). This separation of concerns means the code that initiates cancellation does not need a reference to every operation it cancels -- it just calls abort() once.

The most common usage is cancelling fetch() requests. Pass { signal: controller.signal } as a fetch option. When controller.abort() is called, the browser cancels the underlying HTTP request (actually closing the TCP connection or cancelling the HTTP/2 stream), and the fetch promise rejects with an AbortError DOMException. The request is genuinely cancelled at the network level, not just ignored -- this prevents wasted bandwidth and server load from abandoned requests.

Event listeners gained signal support, making cleanup dramatically simpler. element.addEventListener('click', handler, { signal }) automatically removes the listener when the signal aborts. This eliminates the need to store handler references for manual removeEventListener() calls. A single AbortController can manage dozens of listeners across different elements -- call abort() once in your cleanup function and every listener registered with that signal is removed.

AbortSignal.timeout(ms) creates a signal that automatically aborts after the specified duration. This composes with AbortSignal.any([signal1, signal2]), which creates a signal that aborts when any of the input signals abort. Together, these enable patterns like "cancel this fetch if the user navigates away OR if it takes longer than 5 seconds" without manual timer management.

The .reason property (set via controller.abort(reason)) allows communicating why cancellation occurred. Consumers can inspect signal.reason to distinguish between user-initiated cancellation, timeouts, and other abort causes. If no reason is provided, it defaults to an AbortError DOMException.

Custom async operations integrate with AbortSignal through the abort event. Register a listener on the signal inside your async function: signal.addEventListener('abort', () => { /* cleanup */ }). Always check signal.aborted before starting work -- the signal may have already been aborted before your function was called. The pattern is: check signal.aborted upfront, then listen for the abort event for cancellation during execution.

AbortSignal.any() is particularly valuable for composing cancellation scopes. A React component might create a per-component AbortController (aborted on unmount) and combine its signal with a per-request timeout signal. The fetch cancels if either the component unmounts or the timeout expires, whichever comes first.

In Node.js, AbortController has gained wide adoption beyond fetch. fs.readFile, setTimeout, stream.pipeline, and many other APIs accept abort signals. The events.on() async iterator and events.once() both support signal-based cancellation, preventing resource leaks from listeners that never fire.

For streaming operations, aborting a fetch that uses response.body.getReader() requires careful handling. Calling abort() on the controller cancels the underlying request, but you should also call reader.cancel() to properly close the ReadableStream reader and release its lock. Not doing so can leave the stream in a locked state.

The throwIfAborted() method on AbortSignal provides a convenient checkpoint: call it at various points in a long-running operation, and it throws the abort reason if the signal has been aborted. This avoids repeatedly checking signal.aborted and manually throwing.

Gotchas

  • AbortController is single-use -- once abort() is called, the signal stays aborted permanently; create a new controller for each cancellable operation lifecycle
  • Forgetting to check signal.aborted before starting work -- the signal may already be aborted when your function receives it; always check synchronously before doing anything
  • AbortSignal.timeout() creates a new signal, not a controller -- you cannot manually abort a timeout signal early; combine it with AbortSignal.any() if you need both manual and timed cancellation
  • Not all APIs support abort signals yet -- XMLHttpRequest, WebSocket, and some older APIs ignore the signal; check MDN for per-API support
  • Aborting does not guarantee immediate cleanup -- fetch() abortion cancels the network request, but any already-received response body chunks remain in memory until garbage collected