AbortController against hanging requests and race conditions
A composable that fetches data but cannot cancel an in flight request quietly produces two kinds of problems: stale responses that try to update state belonging to an already destroyed component, and race conditions where a fast second request gets overwritten by a slower first one. AbortController solves both problems once it is consistently built into a custom useFetch composable.
Table of Contents
- 1. Why a running request should be cancelled on unmount
- 2. AbortController basics
- 3. Building your own useFetch composable
- 4. Automatic cancellation on repeated calls
- 5. Cancelling on unmount via onUnmounted
- 6. Error handling: telling AbortError apart from real errors
- 7. Cancelling when reactive parameters change
- 8. Combining a timeout with AbortController
- 9. Testing cancellable composables
- 10. Summary
- 11. FAQ
1. Why a running request should be cancelled on unmount
If a component gets removed while a fetch request it triggered is still in flight, the network call keeps running in the browser regardless, because a plain fetch promise has no connection to a Vue component's lifecycle. Once the response finally arrives, the then handler tries to update a ref belonging to a component instance that has already been discarded. In Vue 3 this usually does not cause a hard error, since the ref itself still exists, but it is wasted work, and combined with further side effects such as toasts or navigation it becomes a real risk for inconsistent behavior.
The problem gets worse in list and search views where users quickly switch between different filters or detail pages. Every switch triggers a new request, but without a cancellation mechanism, every previous request keeps running in the background and can respond in any order. A composable that never cancels therefore has not just a cleanup problem on unmount, but a structural problem on every reuse while the component is still mounted.
2. AbortController basics
The Fetch API has long supported a signal argument, obtained from an AbortController and passed into fetch's options. Calling controller.abort() afterward flips the associated promise not into success but into a rejected state, with an error whose name property is AbortError. The browser actually terminates the underlying network connection in the process, so this is not just an ignored promise but a genuine connection abort that also saves bandwidth.
A single AbortController can be used for exactly one cancellation; once abort() has been called it is spent and must be replaced with a fresh instance for the next request. This one shot nature matters for the design of a composable, because you cannot reuse the controller across requests, you have to create a new one for every request and swap out the reference to the previous controller accordingly.
// Plain example without Vue: AbortController with fetch
const controller = new AbortController()
fetch('/api/products', { signal: controller.signal })
.then((response) => response.json())
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request was cancelled')
return
}
throw error
})
// Somewhere else in the code, e.g. on unmount:
controller.abort()
3. Building your own useFetch composable
A minimal, cancellable useFetch composable keeps data, error, and loading alongside a reference to the currently active AbortController. The actual execute function first creates a new controller on every call, passes its signal into fetch, and stores the controller in a module or closure variable so it can later be cancelled from outside. The advantage of a dedicated composable over a direct fetch call in the component is that this entire cancellation logic stays encapsulated in one place and can be reused in every component that fetches data.
It matters that the composable consistently resets its own internal state whenever a new request starts, so loading correctly stays true during an in flight request and error from a previous failed attempt is not mistakenly shown any longer. This detail work is easy to overlook in practice when fetch logic gets duplicated ad hoc across individual components, whereas in a central composable it only has to be written correctly once.
// composables/useFetch.ts
import { ref, shallowRef } from 'vue'
export function useFetch<T>(url: string) {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const loading = ref(false)
let controller: AbortController | null = null
async function execute() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (err) {
if ((err as Error).name === 'AbortError') return
error.value = err as Error
} finally {
loading.value = false
}
}
function cancel() {
controller?.abort()
}
return { data, error, loading, execute, cancel }
}
4. Automatic cancellation on repeated calls
The decisive line in the composable above is controller?.abort() right at the start of execute. If execute is called again while a request is still running, for instance because the user quickly switches between two search terms, this line immediately cancels the previous request before the new one starts. That means the old request's response can never overwrite the new request's response again, even if the server happens to take longer for the older request than for the newer one.
Without this mechanism, a classic race condition emerges: request A starts, shortly after request B starts, but request A responds later than request B for whatever reason. Without cancellation, the later arriving but actually stale response from request A overwrites the already correctly set state from request B, and the user sees wrong data briefly or permanently. The abort() call at the start of every execute makes this scenario structurally impossible, because a cancelled request never reaches a then branch again.
5. Cancelling on unmount via onUnmounted
For a composable to also clean up correctly when the calling component gets destroyed, it registers an onUnmounted hook internally that calls the cancel function. It matters that onUnmounted is called inside the composable itself and not only in the component that uses the composable, because only that guarantees the cancellation logic comes along automatically, without every calling component having to remember to add it manually.
For composables that can also run outside a component instance, for example inside a Pinia store, onScopeDispose is the more appropriate choice, since it is tied to the effectScope rather than a specific component. In most use cases inside script setup, both behave practically the same, but onScopeDispose still works in places where onUnmounted would emit a warning for lacking a surrounding component.
6. Error handling: telling AbortError apart from real errors
A common mistake is treating every caught error the same way in the catch block and showing the user an error message, even when the request was only deliberately cancelled. A cancelled request is not a real error in the functional sense, but intentional behavior of your own code, and should therefore be explicitly recognized and silently ignored in the catch block by checking the caught error's name property against the value AbortError.
Only after that check should the composable's error ref be set and an error message triggered in the UI. Without this distinction, users typing quickly in a search field would constantly see brief flashes of error messages for requests that technically did not fail at all, but were simply superseded by the next, more current request.
7. Cancelling when reactive parameters change
If the URL or a search parameter depends on a reactive value, the composable is typically combined with a watch that calls execute again on every change. Because execute already cancels the previous controller internally, the watch itself needs no additional cancellation logic, it simply calls execute again on every change, and the composable guarantees that only the most recently started request ever actually results in a visible state change.
A watch with the immediate option set to true runs the first call right away during component setup, while every further change of the watched value automatically triggers a new request that replaces the previous one. This pattern can be refined further by combining the watch with a short debounce, so that very rapid successive changes, for instance every keystroke in a search field, do not each start a fresh request.
8. Combining a timeout with AbortController
Besides manual cancellation on unmount or repeated calls, the same AbortController can also power a timeout, without introducing a second, parallel cancellation mechanism. Modern browsers offer AbortSignal.timeout(ms) for this, returning a ready made signal that cancels itself automatically after the given time, or you can combine several signals via AbortSignal.any() together with your own manually controlled controller signal.
If AbortSignal.any() is missing in the target browser support, the same effect can also be achieved with a plain setTimeout that calls controller.abort() on the same controller also used for manual cancellation. In both cases, error handling in the catch block stays unchanged, because a timeout triggered cancellation produces the same AbortError as a manual cancellation, and both cases can be further distinguished via the signal's reason property if needed.
9. Testing cancellable composables
When testing a cancellable composable, it is worth specifically covering three scenarios: a request that completes normally, a request that gets cancelled by a second call while it is still running, and a request whose component unmounts before the response arrives. With a mocked fetch implementation that reacts to the signal argument and rejects the promise with an AbortError on abort, all three cases can be reproduced deterministically in unit tests, without depending on real network latency.
Especially valuable is a test that explicitly verifies that, after calling execute again while a request is still running, the state that ends up in the data ref belongs to the second request, not the first. This test captures the actual race condition prevention and reliably catches regressions if someone accidentally removes or misplaces the abort() line at the start of execute.
| Scenario | Without AbortController | With AbortController in the composable | Effect |
|---|---|---|---|
| Component unmounts | Request keeps running in the background | onUnmounted calls cancel() | No update to discarded state |
| Second call while first is running | Race condition possible | Previous controller is aborted first | Only the latest response counts |
| User types quickly in a search field | Many parallel, unnecessary requests | Every new keystroke cancels the previous one | Less network load, consistent state |
| Server responds very slowly | UI waits indefinitely | AbortSignal.timeout() cancels after a deadline | Predictable error behavior |
Mironsoft
Vue architecture, Composition API, and Nuxt performance
Vue applications that don't get more complicated with every feature?
We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.
Architecture Review
Checking composables, state management, and component structure for maintainability.
Performance Audit
Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.
Nuxt Integration
Building robust, type-safe SSR/SSG setup and API integration.
10. Summary
Cancellable Vue composables with AbortController at a glance
Core problem
In flight requests outlive unmount and overwrite newer responses
Solution
One AbortController per request, previous controller aborted before a new call
Cleanup
onUnmounted or onScopeDispose calls cancel() automatically
Error handling
Explicitly recognize AbortError and separate it from real errors