AbortController Beyond fetch: Cancellation Patterns We Use in Every React App
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗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.
Further Reading
Frontend Engineering
WebSockets on Vercel Functions: Real-Time Without a Separate Server
We stopped running a separate WebSocket server for realtime features once Vercel Functions started supporting them natively on Fluid Compute. Here's how the upgrade works, when it beats SSE, and what reconnection logic you still own yourself.
Frontend Engineering
Next.js Middleware in 2026: Auth Guards, A/B Tests, and What Belongs at the Edge
We have debugged the same mistake across multiple projects: database calls in middleware.ts that make every matched request pay a round-trip cost before the route runs. Field notes on the patterns that belong at the edge — auth guards, A/B cookie bucketing, locale rewrites — and what does not.