•5 min read

Canceling fetch with AbortController (and the parts that leak)

A support ticket I could not reproduce for two days: a user typed a marketplace name into our admin filter, hit enter too fast, and got results belonging to a shorter string. The input said postgres index, the table showed hits for postgre. The API was innocent. I had two requests in flight and no rule about which answer wins.

Everyone reaches for AbortController at that point, and rightly so. Canceling a fetch is five lines. The rest cost me an afternoon each: where the rejection actually lands, which signals can be reused, and why a page that cancels a lot of requests quietly accumulates listeners.

Canceling the request is the easy part

Create a controller, hand its signal to fetch, call abort() when the work that started the request is over.

const controller = new AbortController()

const res = await fetch('/api/search?q=' + encodeURIComponent(q), {
  signal: controller.signal,
})

// Whatever ends the request's reason to exist calls this.
controller.abort()

The fetch promise rejects with a DOMException named AbortError, so the catch block has to tell a cancellation apart from a real failure, or you show the user an error for something they did on purpose.

try {
  const res = await fetch(url, { signal: controller.signal })
  return await res.json()
} catch (err) {
  if (err.name === 'AbortError') return null
  throw err
}

That is the part everyone writes. Now the parts that bit me.

Close-up of a network patch panel with numbered ports and blue ethernet cables plugged into a few of them

A signal only works once

MDN states it plainly: an AbortSignal can only be used once, and any fetch handed an already-aborted signal rejects immediately. That turns a module-level controller from a neat idea into an outage. Mine sat at the top of a service module so any caller could cancel everything. The first caller to abort it also killed every request that came afterwards, instantly, with no network traffic at all. For an hour it looked like the API was down.

A global kill switch is fine to want, but it has to be a signal you fire only on teardown. Per-component work gets a controller where the work lives.

The rejection is not always at the fetch call

This one made the bug intermittent. A fetch promise resolves as soon as the response headers arrive, and the body is a stream you read afterwards. If the abort lands between those two moments, there is nothing to catch at the fetch call. The failure shows up when you read the body, which rejects with an AbortError too.

const res = await fetch(url, { signal })  // may already be resolved
// abort happens here, between headers and body
const data = await res.json()             // this is where the AbortError lands

So the try block has to cover the body read, not just the request. Mine wrapped only the fetch, so every late abort became an unhandled rejection and a spinner that never stopped.

Timeouts, and two surprises in AbortSignal.timeout()

Fetch has no timeout of its own, so a hung upstream holds the request open until something else gives up. AbortSignal.timeout() fixes that in one line (Node has had it since 17.3).

// Rejects with a TimeoutError DOMException, not an AbortError.
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })

Surprise one: the clock counts active time, not elapsed time. MDN notes that the timeout is effectively paused while the document sits in the back-forward cache or the worker is suspended. A five second timeout on a backgrounded tab is not five seconds of wall time, so this is a softer guard than it looks against a slow backend.

Surprise two: you cannot cancel the timeout. Finishing early does not stop the timer, and dropping the last reference to the signal is not guaranteed to cancel it either. Where I need a deadline that releases resources right away, I use a controller with setTimeout and clearTimeout in a finally.

When a request needs both a deadline and a manual cancel, AbortSignal.any() combines the signals. The combined one takes the reason of whichever input fires first, and if any input is already aborted it starts out aborted too. That matters at mount time: pair a teardown controller that already fired with a fresh timeout and every request fails before it leaves the browser.

What leaks is the listener

Abort signals are event targets, and their lifetime rules are easy to skip. A signal created by a controller is collectable once the signal and its controller are both unreachable, listeners or not. A signal whose abort is driven by something else, such as one from AbortSignal.timeout() or a still-live result from AbortSignal.any(), stays alive while abort listeners are attached. Node's docs are blunt about it: attach listeners with { once: true }, or you leak memory.

The catch is that { once: true } only detaches the listener when the abort fires. On a happy path where nothing aborts, your listener is still sitting on the signal, holding whatever it closed over.

const signal = AbortSignal.any([appSignal, AbortSignal.timeout(30_000)])

function onAbort() {
  stopSpinner()
}

signal.addEventListener('abort', onAbort)
try {
  return await fetch(url, { signal })
} finally {
  signal.removeEventListener('abort', onAbort)
}

Fetch cleans up its own internal abort handling, not yours, so that finally is the point of the snippet. In a component the cleanup goes wherever teardown goes: onScopeDispose in Vue, the effect's return function in React.

Aborting does not undo the work

Abort stops the client from waiting, not the server. By the time the user gives up, the request has been read and the query is running, so an aborted write is a write that still happened and a canceled export is a job the database still pays for. I had to explain that to a product manager who assumed the cancel button rolled things back.

For a search box, cancellation is exactly right: the only thing at stake is which answer reaches the screen, and aborting the previous request before firing the next saves the network trip too. That beats the sequence-number check I used before, though the shape of the problem is the same as the refresh token race across tabs.

These snippets live in Snippet Ark with the rest of the boilerplate I paste into every project, and I have stopped trusting any async code of mine without a way to give up early.