Cancelling a Request Nobody Is Waiting For
AbortController, unmount, and the AbortError that must never be shown to a user — because abandonment is not failure.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do I stop a request whose answer nobody wants any more, and why must the error it produces never reach the user?
Someone types in a search box, changes their mind, and navigates away. They expect the application to move on with them, not to argue about a page they have already left.
Just ignore the response. If the component is gone, the callback does nothing important; the request finishes quietly in the background and the garbage collector deals with the rest.
The request is still consuming a connection. On HTTP/1.1 that is one of a small per-origin budget, so an abandoned search request delays the one the user actually wants (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
- The request is still consuming a connection. On HTTP/1.1 that is one of a small per-origin budget, so an abandoned search request delays the one the user actually wants (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).
- The response still arrives, is still decompressed, and is still parsed on the main thread. Abandoning a request in your head does not abandon the work in the browser (The Real Cost of JavaScript).
- The callback usually is not harmless. It calls
setState, writes to a cache, or replaces the results of a newer query with an older answer (Out-of-Order Responses). - A closure that survives the request keeps its whole scope alive — the component, its props, the DOM nodes it referenced. Repeat that per keystroke and you have a leak with a network trigger (Memory Leaks).
- Once you *do* cancel, the promise rejects. If that rejection flows into the same handler as everything else, the user is shown an error for something they caused deliberately by navigating away.
What is actually happening
In the browser, not in the framework.
AbortControlleris a one-shot signal source.controller.signalis handed tofetch;controller.abort()flips it, and every operation observing that signal stops. The controller cannot be reset — a new request needs a new controller.- An aborted
fetchrejects with aDOMExceptionwhosenameis"AbortError". It is deliberately not aTypeError, precisely so it can be discriminated from a real network failure. - The signal is observable independently:
signal.aborted, and anabortevent. That is how you cancel thingsfetchknows nothing about — a timer, a worker task, a body being read (Talking to a Worker). AbortSignal.timeout(ms)produces a signal that aborts itself, andAbortSignal.any([...])combines several. A per-request deadline and a per-component lifetime are two different reasons to stop, and both should be attached.- Aborting also stops the body read. A
fetchthat resolved and is midway through.json()will have that read rejected too, which matters for large responses. - Cancellation is a *client-side* act. The request may already have reached the server, which may already have applied it. Nothing about
abort()undoes a side effect (Idempotency in API Design).
What this makes the browser do
And which of it is avoidable.
- Closing or releasing the connection, freeing a slot in the per-origin budget for requests the user still cares about.
- Skipping the decompression, parse and reconciliation that the abandoned response would otherwise have caused — usually the largest saving, and the invisible one.
- Releasing the closure graph the pending promise was holding: the component, its props and any DOM references captured in the handler.
- Avoidable work that cancellation does *not* remove: anything the server already did. A cancelled mutation is a cancelled *response*, not a cancelled effect.
One signal, several reasons to stop
There are usually two independent reasons to abandon a request, and they arrive from different places. The component that wanted the answer has gone away, or the deadline you set has passed. Combining them into one signal keeps the request handling simple and — more importantly — keeps the *reporting* correct, because only one of the two is something to tell the user about.
The AbortError check in the catch block is the load-bearing line. Without it, every navigation away from a pending screen produces a user-visible error for an event the user caused on purpose.
1function useOrders(filter: string) {2 const [state, setState] = useState<State>({ status: 'idle' })3 4 useEffect(() => {5 // A fresh controller per effect run. Controllers are one-shot.6 const lifetime = new AbortController()7 const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(DEADLINE)])8 9 setState({ status: 'pending' })10 11 ;(async () => {12 try {13 const res = await fetch(`/api/orders?filter=${encodeURIComponent(filter)}`, { signal })14 if (!res.ok) { setState({ status: 'error', error: classify(res) }); return }15 setState({ status: 'success', data: await res.json() })16 } catch (e) {17 // The one check this whole lesson exists for.18 if (e instanceof DOMException && e.name === 'AbortError') return // silence19 setState({ status: 'error', error: { kind: 'network' } })20 }21 })()22 23 // Unmount, or `filter` changed: this answer is no longer wanted.24 return () => lifetime.abort()25 }, [filter])26 27 return state28}Two subtleties. AbortSignal.any means the deadline also stops the body read, not just the headers. And the cleanup aborts on *every* change of filter, so a fast typist never has more than one search request outstanding — the cancellation is doing the work people usually try to get from a debounce alone.
What an uncancelled request costs
The timeline below is schematic — relative ordering and overlap, not durations. It shows three keystrokes in a search box, each starting a request, with and without cancellation. The shape is what transfers: without cancellation, three responses arrive, three parses run on the main thread, and the last one to land determines what the user sees.
The important row is the fourth one. The response for the *first* query arrives last, after the third query has already rendered, and overwrites it. That is not a cancellation problem being fixed by cancellation — cancellation removes it here, but only generation stamping removes it in general, because a request can complete faster than an abort can travel (Out-of-Order Responses).
- Type "re" → request A — Slow: a cold path on the server, or a large result set.
- Type "refu" → request C — The only answer the user actually wants.
- Parse + render C — Correct results on screen.
- Parse + render A (uncancelled) — The stale answer lands last and wins. The user searched "refu" and is looking at results for "re".
- Abort A and B at keystroke — With cancellation: A and B never reach parse, and the connections are freed for C.
- Main thread free for input — The saving is not bandwidth. It is the parse and render work that never runs on the thread the user is typing on (Long Tasks).
Two separate wins, often confused: cancellation frees connections and main-thread work; generation stamping is what guarantees correctness. Ship both.
The cancellation path, step by step
Cancellation looks like one call, but it is a short pipeline with a failure available at every stage. Most broken implementations are broken at step four or step six — either the controller is not reachable from the teardown, or the resulting rejection is not discriminated and becomes an error message.
Step seven is the one that is easy to forget entirely: the busy state. Aborting without clearing aria-busy or the pending flag leaves the region permanently silent for assistive technology and permanently spinning for everyone else.
- 11. Create a controller per request
Establishes a signal whose lifetime matches the interest in the answer.
fails by Reusing a controller across requests — the second request is aborted before it starts.
- 22. Attach the signal to `fetch`
Lets the browser stop the transfer and the body read on demand.
fails by Passing the controller instead of
controller.signal, which silently does nothing. - 33. Combine with a deadline
Adds a second, independent reason to stop, via
AbortSignal.any.fails by Having only a deadline, so a route change leaves the request running.
- 44. Abort from teardown
Unmount, route change, or the next supersedig query flips the signal.
fails by A teardown that never runs, or one that runs on every render and cancels the request it just started.
- 55. The browser stops the work
Connection released; transfer, decompression and parse abandoned.
fails by Nothing — but note the server may already have finished, and a mutation may already have applied.
- 66. The promise rejects with `AbortError`
Gives you a discriminable, deliberate outcome.
fails by A catch-all handler that reports it as a network failure. This is the defining bug of the lesson.
- 77. Clear the busy state
Returns the region to a state where later updates are announced and rendered.
fails by Leaving
aria-busy="true"or a pending flag set, so the UI is silent and spinning forever (Live Regions and Announcement).
Steps 5 and 6 are where the mental model has to bend: the work stops on the client, the rejection is not a failure, and neither fact says anything about what the server did.
How to build it
Most important first.
- Create one controller per request and abort it in the teardown of whatever owns the request — the effect cleanup, the route transition, the next keystroke. The lifetime of the request should be the lifetime of the interest in it.
- Discriminate
AbortErrorbefore anything else in the catch block, and return silently. This is the single most important line in the lesson: an abandoned request has no user-facing outcome at all. - Combine a lifetime signal with a deadline signal via
AbortSignal.any. They fire for different reasons and both are legitimate; only one of them should produce an error message. - Abort the *previous* request on every new one for a query that supersedes itself — search-as-you-type is the canonical case, and cancelling is more reliable than debouncing alone because it also handles the slow response (Five Components, One Request).
- Be careful with shared requests: if several components deduplicate onto one in-flight promise, the last subscriber leaving should abort, and one subscriber leaving must not (Five Components, One Request).
- Never auto-cancel a mutation on unmount. The user pressed Save; navigating away does not mean they changed their mind about it, and the server has probably applied it anyway (Optimistic UI).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- An abandoned request must announce nothing. A
role="alert"that fires because the user navigated away is an interruption caused by their own successful action. - When cancellation is user-visible — an explicit Cancel button on a long operation — it needs the same treatment as any other outcome: announce the cancellation, and return focus to the control that started it (Focus Management).
- Clear the busy state when you abort. A live region left at
aria-busy="true"after cancellation suppresses every later announcement in that region (Live Regions and Announcement). - A Cancel control must be a real
button, reachable by keyboard, and must not be the only way out of a modal —Escapeshould do the same thing (Accessible Component Patterns).
What can go wrong
- The catch-all:
catch (e) { setError(e) }with no abort check, so every navigation away from a loading screen leaves an error toast behind it. - A controller created outside the effect and reused, so the second request is aborted at birth by a signal that is already flipped.
- Aborting on every render rather than on unmount — a dependency array that changes identity each pass turns cancellation into an infinite request loop.
- Cancelling a mutation and reporting it as failed, so the user retries an operation the server already performed. This is how cancellation creates duplicates (Retries, and the Duplicate Order).
- A deadline signal without a lifetime signal: the request survives the route change and lands on a component that no longer exists.
- The mitigation failing: aborting the shared, deduplicated promise when one of five subscribers unmounts, cancelling the request for the other four (Five Components, One Request).
- The abort and the response race.
abort()can be called on a request whose bytes are already in the browser, in which case there is nothing to cancel and the handler must still not run. - Cancelling a superseded search does not guarantee the newer search resolves later; if the new query is slower, the *old* one may still have to be discarded by generation rather than by cancellation (Out-of-Order Responses).
- Abort and retry race when both are wired to the same state. A retry scheduled by a backoff timer can fire after the component has aborted, sending a request for a screen that no longer exists (Retries, and the Duplicate Order).
- Cancellation gives no guarantee the server did not act. Treating "cancelled" as "did not happen" in the UI is a correctness claim the client is not entitled to make (Idempotency in API Design).
- Aborting does not un-send credentials. Anything already on the wire has been sent, including the cookie the browser attached automatically (Cross-Site Request Forgery).
- A cancelled upload can leave a partial object on the server. What happens to it is the server's decision, and the UI should not report success or failure for a state it cannot observe (File Upload UX).
- Do not use cancellation as an access control. Aborting the request that fetches data the user should not see means the request was still made and the response was still generated.
- "Aborting cancels the work on the server." It stops the client waiting and releases the connection. Whether the server notices, and what it does if it does, is entirely the server's business — most simply finish the work (Graceful Shutdown: The 502 Spike Nobody Investigates in Backend).
- "
AbortErrormeans something went wrong." It means something was deliberately stopped. Treating it as a failure is the defining bug of this lesson. - "Debouncing makes cancellation unnecessary." Debouncing reduces how many requests you start. It does nothing about the one that was started and is now slow (Five Components, One Request).
- "One controller can be reused."
abort()is permanent for that controller. A second request needs a second controller. - "If I ignore the response the work does not happen." The transfer, the decompression and the parse all happen. Ignoring is not cancelling.
Measuring it, and what changes in the field
- The Network panel marks cancelled requests distinctly. A search box that leaves a trail of completed rather than cancelled requests is not cancelling anything (Debugging the Network).
- Count aborts separately from errors in your error tracking. A spike in reported "network errors" that turns out to be
AbortErroris a classification bug, not an outage (Frontend Error Tracking). - Watch main-thread parse tasks in the Performance panel during rapid typing. Work for responses nobody is waiting for shows up there before it shows up anywhere else.
- On a slow network, cancellation matters most: requests overlap for longer, so more of them are in flight and abandoned at any moment.
- On a fast network, a request may complete before the abort is processed. Cancellation is best-effort and always has been (Reasoning About Races: A Method, Not an Instinct in Concurrency).
- On a large response, the saving from abandoning the parse can exceed the saving from abandoning the transfer.
- In a long-lived tab, uncancelled requests accumulate their closures. The symptom is the classic one — the application gets slower the longer it is used (Long-Lived Clients and Version Skew).
- Cancelling aggressively wastes server work that was already done. A request abandoned just before it would have answered cost the backend everything and delivered nothing, and the retry will cost it all over again.
- Cancel-on-unmount interacts badly with caching: a request abandoned because a component unmounted might have been about to fill a cache that the *next* component would have used.
- Every request now carries a controller, a signal and a discriminated catch. It is real ceremony for a case that only shows up under conditions your development machine rarely produces.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERAL
AbortController,AbortSignal, and theAbortErrorname are specified by the DOM and Fetch standards and behave the same across modern engines. What differs is age:AbortSignal.timeout()andAbortSignal.any()arrived considerably later than the controller itself, so older browsers need the manual controller-plus-timer form. - FRAMEWORK-SPECIFICWhere the abort belongs depends on the framework's teardown model: React puts it in an effect cleanup that also runs between renders in development strict mode, Vue in a scope disposal, Svelte in
onDestroy. The signal is identical; the hook that fires it is not, and a cleanup that runs more often than expected turns cancellation into a request loop. - SIMPLIFIEDThe lesson treats abort as instantaneous. In practice it is a request to stop that races the network stack, so a response can arrive for a request you have already cancelled — which is exactly why generation stamping and cancellation are complementary rather than alternatives.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — a cancelled request is an unknown outcome, not a negative one. The client and the server can disagree about whether an operation happened, and only an idempotency key lets them find out.