Race Conditions
When two requests are in flight the slower one can land last and overwrite the newer answer; abort it or ignore it by request id.
watch(source, async (value) => { const c = new AbortController(); onWatcherCleanup(() => c.abort()); /* fetch with c.signal */ })A race condition here means two requests for the same piece of state are in flight at once, and the order they return in is not the order they were sent in. Type ab in a search box, then abc. The request for abc answers in 150ms, the request for ab takes 400ms. The user sees results for abc appear, then be replaced by results for a query they have already finished typing.
Why a watcher alone does not fix it
Vue runs a watch callback every time the source changes, and does not cancel the previous run. Two callbacks are now both sitting at an await, and whichever resolves last writes last. Nothing about reactivity orders them.
Two fixes
The first is to abort: create an AbortController, pass controller.signal to fetch, and abort it when the watcher re-runs. The aborted fetch rejects with an error whose name is AbortError, which you must catch and ignore - it is not a failure the user should hear about. The second is a request id: increment a counter per request, and when a response arrives, apply it only if its id is still the newest. Use abort when you also want to stop the network work; use an id when you cannot cancel the work, for example a cached layer or a non-fetch client.
What trips people up
Aborting is per request. Reusing one controller for every request means the first abort poisons all later ones, because an aborted signal stays aborted.
Example
<script setup lang="ts">
import { ref, watch, onWatcherCleanup } from 'vue'
const props = defineProps<{ query: string }>()
const results = ref<string[]>([])
const error = ref<string | null>(null)
watch(
() => props.query,
async (query) => {
if (query === '') {
results.value = []
return
}
// Registered before the first await, so it is attached to this run.
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
error.value = null
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal
})
results.value = (await response.json()) as string[]
} catch (err) {
if ((err as Error).name !== 'AbortError') {
error.value = 'Search failed'
}
}
},
{ immediate: true }
)
</script>
<template>
<p v-if="error">{{ error }}</p>
<ul>
<li v-for="result in results" :key="result">{{ result }}</li>
</ul>
</template>
<style scoped>
ul {
margin: 0;
padding-left: 1rem;
}
</style>When to use it
- Stop a search box from showing results for an earlier prefix of the query.
- Cancel an in-flight detail request when the user clicks a different row.
- Abort a pending fetch when the user navigates away from the view.
More examples
The bug, so you can recognise it
Worth reading closely, because this code looks correct and passes every test where the server answers in a consistent time. It fails on a real network, on the slow request only, and usually only for the person who types fast. If a list sometimes shows results for a prefix of what is in the input, this is why.
<script setup lang="ts">
// SearchResultsRacy.vue - this is the version that shows stale results
import { ref, watch } from 'vue'
const props = defineProps<{ query: string }>()
const results = ref<string[]>([])
watch(() => props.query, async (query) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
results.value = (await response.json()) as string[]
})
// Typing "ab" and then "abc":
// t=0ms request "ab" sent
// t=80ms request "abc" sent
// t=150ms "abc" answers -> results.value = results for "abc"
// t=400ms "ab" answers -> results.value = results for "ab" <- wrong
</script>
<template>
<ul>
<li v-for="result in results" :key="result">{{ result }}</li>
</ul>
</template>Latest-wins by request id
Use this when the work cannot be cancelled: a client library with no signal option, a database read, or a computation in a worker. The request still completes and still costs bandwidth - the only thing the guard buys you is that it cannot overwrite newer state. Note that latest is a plain let, not a ref: it is bookkeeping, nothing renders from it, and making it reactive would only add tracking work.
// src/composables/useLatest.ts
import { ref, type Ref } from 'vue'
export function useLatest<T>(initial: T): {
value: Ref<T>
run: (task: () => Promise<T>) => Promise<void>
} {
const value = ref(initial) as Ref<T>
let latest = 0
async function run(task: () => Promise<T>): Promise<void> {
const id = ++latest
const result = await task()
// A response from an older call is read and thrown away.
if (id === latest) {
value.value = result
}
}
return { value, run }
}
// const { value, run } = useLatest<string[]>([])
// run(() => slowSearch('ab')) // id 1, resolves second, 1 !== 2 so ignored
// run(() => fastSearch('abc')) // id 2, resolves first, applied
// Output: ['abc']Aborting on unmount as well as on change
A single request per component lifetime has no race with itself, but it does race with navigation: the user leaves, the response arrives, and the handler writes to a component that no longer exists. Aborting on unmount closes that. One controller is correct here only because there is exactly one request - the moment load() can run twice, the controller has to move inside it.
<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
const profile = ref<{ name: string } | null>(null)
const controller = new AbortController()
onUnmounted(() => controller.abort())
async function load(): Promise<void> {
try {
const response = await fetch('/api/profile', { signal: controller.signal })
profile.value = (await response.json()) as { name: string }
} catch (err) {
if ((err as Error).name !== 'AbortError') {
profile.value = null
}
}
}
void load()
</script>
<template>
<p>{{ profile?.name ?? 'Loading' }}</p>
</template>
Discussion