AbortController: Cancelling Fetch in JavaScript
Photo by Unsplash on Unsplash
Table of Contents
The request that finishes after you stopped caring
The classic bug goes like this. A user types in a search box. Each keystroke fires a request. The responses come back out of order, because networks don't promise anything about ordering, and the results for "ca" arrive after the results for "cat".
Your UI now shows results for a query the user isn't looking at. Nobody typed "ca" and pressed enter, but that's what's on screen.
The other version is a component that unmounts while a request is in flight. The response lands, your handler calls setState on something that no longer exists, and you get a warning in the console or a memory leak depending on the framework.
Both have the same root cause: you started work you no longer want, and nothing told it to stop. `AbortController` is the browser's answer, and it's been available everywhere for years now.
Wiring a signal into fetch
```js const controller = new AbortController();
fetch('/api/search?q=cat', { signal: controller.signal }) .then(r => r.json()) .then(render);
// somewhere else, later controller.abort(); ```
`controller.signal` is an `AbortSignal`. You hand it to `fetch` and keep the controller. Calling `abort()` on the controller cancels anything holding that signal.
Two properties of the design catch people out.
A controller is single use. Once aborted, that signal stays aborted forever. If you're cancelling per keystroke you create a new controller each time, not one you reuse. Reusing an aborted controller means every subsequent request fails instantly, which produces a genuinely confusing bug.
One signal can cancel many requests. Pass the same signal to five fetches and one `abort()` kills all five. That's ideal for a page teardown where you want everything in flight to stop at once.
The search box pattern in full:
```js let inFlight;
async function search(q) { inFlight?.abort(); // cancel the previous one inFlight = new AbortController(); try { const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: inFlight.signal }); render(await res.json()); } catch (err) { if (err.name !== 'AbortError') throw err; } } ```
That's the whole fix for out of order results. The previous request is cancelled before the new one starts, so only the latest can ever render. Note the `encodeURIComponent`, which matters for the same reasons covered in the URL encoding explainer.
Catching AbortError without swallowing real bugs
When a fetch is aborted, the promise rejects. It does not resolve, and it does not quietly disappear. So an unguarded `await fetch(...)` inside a try/catch will land in your catch block, and if that block shows an error toast, your users get "Something went wrong" every time they type a letter.
The wrong fix, which I've seen shipped more than once:
```js catch (err) { // silence } ```
That makes the symptom go away and blinds you to every genuine network failure at the same time. Now a real 500 is also silent.
The right shape checks the error name:
```js catch (err) { if (err.name === 'AbortError') return; // expected, ignore reportError(err); // everything else is real } ```
You can also check `signal.aborted` after the fact if you'd rather branch on state than on the error.
One clarification worth having, because it trips people up in code review: aborting cancels the *client side* of the request. The browser stops waiting and tears down the connection, but the server may well have already received the request and may finish processing it. If the endpoint has side effects, aborting is not a rollback. Cancelling a POST that charges a card does not un-charge anything, which is exactly why those endpoints need the protection described in the idempotency guide.
The timeout shortcut nobody uses
The old workaround was a controller plus a `setTimeout`, then remembering to clear it:
```js const c = new AbortController(); const t = setTimeout(() => c.abort(), 5000); try { const res = await fetch(url, { signal: c.signal }); } finally { clearTimeout(t); } ```
That works and it's what most codebases still contain. There's a one liner now:
```js const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); ```
`AbortSignal.timeout()` returns a signal that aborts itself after the given milliseconds. No timer to clear, no controller to hold. The rejection is a `TimeoutError` rather than an `AbortError`, which is useful because you can distinguish "the network was too slow" from "the user navigated away" and handle them differently.
There's a companion for combining signals:
```js const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(5000)]); ```
That aborts on whichever fires first, which is the realistic case: give up after five seconds, or when the user leaves, whichever comes sooner. MDN's AbortController reference covers the full surface including `AbortSignal.abort()` for a signal that's already aborted, handy in tests.
Beyond fetch
`addEventListener` accepts one:
```js el.addEventListener('scroll', onScroll, { signal }); window.addEventListener('resize', onResize, { signal }); // one call removes both controller.abort(); ```
This is genuinely excellent. Instead of keeping references to every handler so you can remove them individually, you pass one signal to all of them and abort once during cleanup. It has quietly deleted a lot of teardown code for me.
In React, the cleanup function is where this belongs:
```js useEffect(() => { const c = new AbortController(); fetch('/api/data', { signal: c.signal }) .then(r => r.json()) .then(setData) .catch(err => { if (err.name !== 'AbortError') setError(err); }); return () => c.abort(); }, []); ```
Returning `c.abort` from the effect means the request is cancelled on unmount and on every re-run, which handles both the leak and the stale response.
Plenty of other APIs accept signals too, including streams and some third party libraries that have adopted the convention. Axios supports it. If you're writing your own async helper that does meaningful work, accepting an optional signal is a small courtesy that makes it composable with everything else.
I've started treating it as a default parameter on anything async I write:
```js async function loadReport(id, { signal } = {}) { const res = await fetch(`/api/reports/${id}`, { signal }); if (!res.ok) throw new Error(`Report ${id} failed: ${res.status}`); return res.json(); } ```
Costs nothing when unused, and the moment a caller needs to cancel, it already works. Passing `undefined` as the signal is perfectly valid, so there's no branching required.
The mental shift that helped me most was to stop thinking of cancellation as an error path and start thinking of it as a normal outcome. A request that was cancelled did not fail. It was withdrawn, deliberately, by your own code. Once you frame it that way, the `if (err.name === 'AbortError') return` line stops feeling like a workaround and starts reading as what it is, which is handling the expected case. For the underlying mechanics of why a cancelled promise still settles rather than vanishing, the event loop walkthrough is the right background reading.
Frequently Asked Questions
How do I cancel a fetch request in JavaScript?
Create an AbortController, pass its signal into fetch as an option, then call abort on the controller when you want to stop. The fetch promise rejects with an AbortError, which you catch and ignore. Controllers are single use, so create a new one for each request you might want to cancel.
What is AbortError and should I handle it?
It's the error a fetch promise rejects with when you abort it. You should catch it and return quietly, because it's expected rather than a failure. Check err.name equals AbortError specifically instead of swallowing all errors, or you'll hide genuine network problems at the same time.
Does aborting a fetch stop the server processing the request?
No. Abort cancels the client side, so the browser stops waiting and tears down the connection, but the server may already have received and be processing the request. For endpoints with side effects that means aborting is not a rollback, which is why they need [idempotency keys](/blog/what-is-idempotency-in-apis-explained).
How do I add a timeout to fetch?
Use AbortSignal.timeout with a millisecond value and pass it as the signal. It aborts itself after that duration with no timer to clean up. The rejection is a TimeoutError rather than an AbortError, so you can tell a slow network apart from a user cancellation.
Can one AbortController cancel multiple requests?
Yes. Pass the same signal to as many fetch calls as you like and a single abort cancels all of them, which suits tearing down a page or view. Remember the controller is single use though, so you can't reuse it after aborting; create a fresh one for the next batch.
Does AbortSignal work with event listeners?
Yes, and it's underused. Passing a signal in the options object of addEventListener means calling abort removes every listener registered with that signal at once. It replaces keeping individual handler references around purely so you can remove them later, which cuts a lot of cleanup code.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools