Making Vue Composables Cleanly Cancellable with AbortController
AI generated
{ }
Vue 3 · Composables · Fetch
Making Composables Cancellable
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.

15 min read AbortController useFetch

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

11. FAQ: Cancellable Vue composables with AbortController at a glance

1Why should a fetch request be cancelled when a component unmounts?
Without cancellation, the request keeps running in the browser and later tries to update a ref belonging to an already discarded component. That wastes network bandwidth and can cause inconsistent behavior when combined with further side effects.
2What is the difference between an AbortError and a real network error?
An AbortError only occurs because your own code called controller.abort(), while a real error is triggered by things like a failed HTTP status code or a dropped connection. Both end up in the catch block but should be handled differently.
3Can I reuse an AbortController for multiple requests?
No. An AbortController is spent once abort() has been called and must be replaced with a fresh instance for every new request.
4How does AbortController prevent race conditions with rapidly changing requests?
By having every new call to the composable first cancel the controller of the previous, still running request. A cancelled request never reaches a successful then branch again, so only the response of the most recently started request ever sets state.
5Should I call onUnmounted inside the composable or in the component?
Inside the composable. Only that guarantees every component using the composable automatically benefits from the cancellation logic without having to remember it itself.
6When is onScopeDispose preferable to onUnmounted?
When the composable can also run outside a specific component instance, for example inside a Pinia store, since onScopeDispose is tied to the effectScope rather than to a component.
7How do I combine a timeout with AbortController?
Via AbortSignal.timeout(ms) for a ready made, self cancelling signal, or via AbortSignal.any() combined with your own manual controller signal; if browser support is missing, a plain setTimeout calling abort() works too.
8Does every component need to check itself whether an error is an AbortError?
No, that check belongs inside the composable itself. The component only ever sees real, functionally relevant errors through the error ref, while cancellations are handled silently internally.
9Does this pattern also work with Axios instead of the native Fetch API?
Yes, Axios has supported the signal argument from AbortController since version 0.22, so the same composable pattern can be applied there with minimal adjustments.
10How do I test that race condition prevention actually works?
With a mocked fetch implementation that resolves two requests at different speeds, and an assertion that the data ref ends up holding the state of the most recently started request, not the first one.