Skip to main content
AbortController Beyond fetch: Cancellation Patterns We Use in Every React App

AbortController Beyond fetch: Cancellation Patterns We Use in Every React App

July 30, 2026
Frontend Engineering
6 min read

Key takeaways

`AbortController` creates an `AbortSignal`; calling `controller.abort(reason)` flips `signal.aborted` to true and rejects any in-flight `fetch` that was given that signal.

An AbortController is one-shot. Once aborted it stays aborted, so every new request needs a fresh controller.

`AbortSignal.timeout(ms)` returns a signal that aborts with a `TimeoutError` DOMException — it replaces hand-rolled `setTimeout` plus `abort()` plus `clearTimeout` wiring.

`AbortSignal.any([a, b])` merges cancellation sources: the returned signal aborts as soon as the first input signal aborts.

Aborting a request cancels the response, not the server-side work. We never use abort to undo a write; we abort reads freely.

What is AbortController and how does the signal actually work?

AbortController is a built-in browser and Node.js class that produces a single AbortSignal, and that signal is the web platform's standard way to say stop this work. We create a controller, hand its `signal` to any API that accepts one, and call `controller.abort(reason)` when the work is no longer needed. Every consumer of the signal reacts at once: an in-flight `fetch` rejects, listeners registered with the signal are removed, and our own code can check `signal.aborted` or call `signal.throwIfAborted()` inside a loop.

The part that trips teams up: a controller is one-shot. There is no reset. Once `abort()` has been called, that signal is aborted forever, and any fetch started with it fails immediately. Every new request gets a new AbortController.

How do we cancel a stale fetch in a React useEffect?

The classic race condition: a search box fires one request per keystroke, and a slow early response resolves after a fast later one, overwriting fresh results with stale data. The fix is to abort the previous request in the effect cleanup — create an `AbortController` inside `useEffect`, pass `controller.signal` to `fetch`, and return `() => controller.abort()` as the cleanup — so only the latest request can ever reach state.

React runs the cleanup before re-running the effect, so fast typing aborts each stale request the moment the next one starts. The same cleanup fires on unmount, which kills the setState-after-unmount class of warnings at the source instead of guarding with an `isMounted` flag. TanStack Query does this automatically — the `queryFn` receives `{ signal }`, and passing it through to `fetch` is all it takes.

What does AbortSignal.timeout() replace?

`AbortSignal.timeout(ms)` returns a signal that aborts automatically after the given number of milliseconds, so a deadline becomes one expression: `fetch(url, { signal: AbortSignal.timeout(8000) })`. It replaces the pattern most codebases hand-rolled for years: create a controller, call `setTimeout` to abort it, then remember to `clearTimeout` on success.

Two details matter. First, a timeout signal aborts with a `TimeoutError` DOMException, while a manual `abort()` produces an `AbortError` — error handling can tell a deadline from a deliberate cancel. Second, `AbortSignal.timeout()` works in all modern browsers and in Node.js 18+, so the same code runs in route handlers and server-side jobs too.

How do we combine a timeout with a user cancel button?

`AbortSignal.any(signals)` takes an array of signals and returns one that aborts as soon as any input aborts — the cancellation equivalent of `Promise.race()`. Our most common use is a long AI generation request that should stop when the user clicks cancel or when a hard deadline passes, whichever comes first: `AbortSignal.any([controller.signal, AbortSignal.timeout(30000)])`.

The merged signal's `reason` comes from whichever source fired first, so `err.name` still says whether the user cancelled (`AbortError`) or the deadline hit (`TimeoutError`). `AbortSignal.any()` is newer than the rest of the API — Chrome 116+, Safari 17.4+, Firefox 124+, Node.js 20.3+ — but that covers evergreen browsers, and we use it without a polyfill.

Can one signal remove many event listeners at once?

Yes. `addEventListener` accepts a `signal` option, and aborting that signal removes every listener registered with it. This is our default for drag interactions, keyboard-shortcut scopes, and any widget that attaches listeners to `window` or `document`: no stored function references, no matching `removeEventListener` calls, one `abort()` tears everything down.

In React this pairs cleanly with `useEffect`: register every listener with one signal, return `() => controller.abort()` as the cleanup, and teardown can never drift out of sync with setup.

How do we tell a cancellation from a real failure?

An aborted fetch rejects, so cancellations land in the same `catch` block as genuine network errors — and reporting them blindly fills the error tracker with noise every time a user navigates away mid-request. The discriminator is the error's `name` property: `AbortError` means we cancelled on purpose and can ignore it, `TimeoutError` means a deadline hit and is worth counting, and anything else is a real failure that should be rethrown.

Passing a custom value to `abort(reason)` makes that value the rejection instead of the default DOMException. That is useful when the abort should carry context, but it also means an `AbortError` name check will not match — pick one convention per codebase and stick to it.

When should we not abort a request?

Aborting cancels the response, not the server-side work. By the time `abort()` runs on a POST, the server may already have committed the write — closing the connection rolls nothing back. Our rule: abort reads freely and aggressively; let writes finish. When a user genuinely needs to cancel a write, an idempotency key plus an explicit compensating request is the honest design; an aborted POST that maybe-committed is not.

The rule has a server-side mirror: in a Next.js route handler, `request.signal` aborts when the client disconnects, and long streaming work should check it so the server stops paying for output nobody is reading.

FAQ

Q: Does aborting a fetch cancel the request on the server?

A: No. `abort()` closes the client connection, but work the server already started may run to completion. Treat abort as client-side cleanup, and design writes with idempotency keys if cancellation matters.

Q: Can we reuse an AbortController after calling abort()?

A: No. A controller is one-shot — once aborted, its signal stays aborted and any fetch given that signal rejects immediately. Create a new controller per request.

Q: What error does an aborted fetch throw?

A: A DOMException named `AbortError` for a manual `abort()`, or `TimeoutError` when the signal came from `AbortSignal.timeout()`. A custom reason passed to `abort(reason)` becomes the rejection value instead.

Q: Does TanStack Query use AbortController under the hood?

A: Yes. The `queryFn` receives an object containing `signal`; pass it to `fetch` and superseded or unmounted queries are aborted automatically.

Q: Is AbortSignal.any() safe to use without a polyfill?

A: For evergreen targets in 2026, yes: it shipped in Chrome 116, Safari 17.4, Firefox 124, and Node.js 20.3. For older browsers, feature-detect and fall back to a single controller.