The RuntimeMay 14, 2026·7 min read·

AbortController doesn't cancel — it signals

Every senior FE dev thinks `controller.abort()` kills a fetch. The spec is more nuanced. Abort is a signal broadcast to whoever's listening. Promise rejection is mandated; network-layer termination is implementation-defined. The distinction matters when you're combining signals or debugging leaks.

Step through what actually happens when you call controller.abort() on an in-flight fetch:

six-step lifecycle · hover / click a row
step 1 / 6 · your code
t+0ms
Caller wires the controller's signal into fetch. The fetch() call returns a Promise immediately; the network request begins.
Auto-playing. The Promise rejection and the network termination are two separate events.

AbortController is the JavaScript API everyone uses and few understand. Most tutorials describe it as "a way to cancel a fetch". Close, but wrong in a way that matters. The WHATWG DOM spec defines AbortController as a signalling primitive: a flag you flip. Anyone holding the signal can choose to react. The network-layer termination that usually follows is separate — handled by the consuming API (fetch, in this case).

The distinction is small when everything works. It becomes load-bearing when you're combining signals, writing your own abortable code, or debugging a cancelled request that still shows up in server logs.

This post walks the spec, the lifecycle, and the four production patterns — including the one every team gets wrong.

tl;dr

AbortController.abort() flips a flag on its AbortSignal. Any code holding the signal sees signal.aborted === true synchronously. The Fetch spec mandates that fetch() reject its Promise with an AbortError; most browsers ALSO terminate the HTTP request on the network layer, but that's "SHOULD", not "MUST". Signals are one-shot — once aborted, forever aborted. New logical operation = new controller. Prefer AbortSignal.timeout() over homemade setTimeout+abort; use AbortSignal.any() to combine multiple signals.

What the spec actually says

WHATWG DOM defines abort as a signalling mechanism. Two clauses worth internalising:

The abort(reason) method steps are to signal abort on this's signal with reason if it is given.

APIs that rely upon AbortController are encouraged to respond to abort() by rejecting any unsettled promise with the AbortSignal's abort reason.

Two observations. First, abort() does one thing: flip the signal. Second, what consuming APIs do with the signal is a recommendation, not a requirement. The Fetch spec happens to mandate rejection for fetch specifically — but anything else wiring AbortSignal into its own API has latitude.

The three entities

Three objects, three distinct roles:

  • — the remote control. You create one, keep it around, and call .abort() when you want to stop.
  • AbortSignal — the event channel. Accessed via controller.signal. Passed into APIs that accept it. Exposes .aborted, .reason, and the abort event.
  • The consuming API — fetch, Web Streams, addEventListener, your own code. Responsible for listening to the signal and reacting.

The controller and signal are permanently paired. Calling controller.abort() flips signal.aborted to true and fires the abort event. Any consumer listening to that event does whatever it was designed to do.

The network-layer story

For fetch specifically, three things happen when you call controller.abort():

  1. signal.aborted becomes true synchronously.
  2. fetch's Promise rejects with an AbortError DOMException — Fetch spec mandates this.
  3. The browser usually terminates the in-flight HTTP request.

The third point is the nuance. Chrome, Safari, and Firefox all terminate aborted fetches at the network layer — they send an RST on the TCP connection or close the HTTP/2 stream. But that's implementation, not spec. The Fetch spec says consumers may terminate; it doesn't say they must.

What this means in practice:

  • The Promise rejection is guaranteed. Your code always sees the AbortError.
  • Server-side cancellation is usually but not always fast. A POST whose body is already in-flight may still reach the server; the server may log a truncated request.
  • For XHR (legacy), abort behaviour is similar — the request-progress events stop, but server-side visibility depends on timing.

If your cancellation story needs (the request must not have side effects if aborted), build that at the protocol layer — idempotency tokens, transactional writes. Don't rely on abort() alone.

Four patterns every senior dev should have in muscle memory

manual abort on unmount
useEffect(() => {
  const controller = new AbortController();

  fetch("/api/users", { signal: controller.signal })
    .then(r => r.json())
    .then(setUsers)
    .catch(err => {
      if (err.name === "AbortError") return; // expected
      setError(err);
    });

  return () => controller.abort();
}, []);
what this gives you
Cancels stale requests when the component unmounts or the effect re-runs with new inputs.
gotcha
Catch the AbortError and ignore it. Otherwise unmount errors bubble to your error boundary.

The four that cover 95% of production use:

  1. Manual abort on unmount. Classic React pattern. useEffect creates a controller, returns a cleanup that calls abort(). Prevents stale setState calls after unmount. Always catch AbortError in the handler — it'll fire on every unmount.

  2. AbortSignal.timeout(ms). Static method shipped in Chrome 103 / Safari 16 / Firefox 100. Returns a signal that auto-aborts after the deadline. Cleaner than new AbortController() + setTimeout(() => controller.abort(), ms). Error name is TimeoutError, not AbortError — branch on it if you need to distinguish.

  3. AbortSignal.any([...signals]). Static method shipped in Chrome 116 / Safari 17.4 / Firefox 124. Combines multiple signals into one. Fires if ANY parent aborts. Perfect for "cancel on any of (user click, 5-second deadline, navigation away)".

  4. Don't reuse a controller across operations. This is the bug I see most. A signal is one-shot — once aborted, always aborted. A new request with an already-aborted signal rejects immediately. Scope the controller to the logical operation.

The React pattern

Combine the first pattern with the second for the one-liner every React data-fetching hook should have:

useEffect(() => {
  const controller = new AbortController();
  const signal = AbortSignal.any([
    controller.signal,
    AbortSignal.timeout(10_000),
  ]);
 
  fetch(`/api/users/${id}`, { signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((err) => {
      if (err.name === "AbortError" || err.name === "TimeoutError") return;
      setError(err);
    });
 
  return () => controller.abort();
}, [id]);

Unmount cancels via the cleanup. Ten seconds elapsed and the server didn't respond? The timeout aborts. Neither case hits the error handler — both are expected cancellations.

The catch branch is load-bearing. Without it, every unmount during a pending fetch throws a "state update on unmounted component" warning (React 17) or silent error (React 18+).

Extending AbortController to your own APIs

Any async operation that might want to be cancelled should accept a signal. The contract is straightforward:

async function poll(url: string, { signal }: { signal?: AbortSignal } = {}) {
  while (!signal?.aborted) {
    const res = await fetch(url, { signal });
    const data = await res.json();
    if (data.done) return data;
    await new Promise((r) => setTimeout(r, 1000));
  }
  throw new DOMException("aborted", "AbortError");
}

Three good habits in that shape:

  • Check signal?.aborted at every iteration boundary. The signal might have flipped during your await.
  • Pass the signal down to nested fetch calls. Otherwise they can't be cancelled mid-flight.
  • Throw an AbortError DOMException when your own code bails out. Callers know that name and handle it.

The throwIfAborted() method makes this one line cleaner:

while (!signal?.aborted) {
  signal?.throwIfAborted();
  // ... rest
}

Shipped in Chrome 100 / Safari 15.4 / Firefox 97.

The debugging checklist

When an abort-related bug shows up:

  1. Is the error name AbortError or TimeoutError? If you're conflating them, the timeout path gets handled as "user cancel" and your retry logic is wrong.
  2. Is the controller reused across operations? Run console.log(signal.aborted) before the operation; if it's already true, you're using a dead controller.
  3. Does your server log show the request arriving, then orphaning? Network-layer abort is best-effort. For write operations, build idempotency at the protocol level.
  4. Does your error boundary fire on unmount? You're missing the AbortError catch. Every fetch that can abort needs one.

Primary sources