watchEffect + fetch, measured โ the await that eats your dependencies, the silent 112-fetch loop, and onWatcherCleanup done rightwatchEffect is Vue's "just works" reactivity primitive: it runs immediately, tracks whatever you read, and re-runs when any of it changes. Then you make the callback async to fetch some data, and three different traps open up โ and the worst two are completely silent.
Everything below was reproduced and measured in a real Chrome on Vue 3.5.43 (Vite, Composition API) with an instrumented window.fetch before publishing. The numbers โ an effect that never re-fires with zero warnings, 112 fetches by the 5-second mark from four lines of innocent code, a page-3 header over page-2 data, exactly 1 aborted request with the fix โ are observed counts, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CodeSandbox, CI โ anywhere. When you want your own schema it's one curl or one click โ see ยง10.
onWatcherCleanup needs Vue 3.5+; we measured on 3.5.43.Rule buried in the docs: watchEffect only tracks reactive reads that happen before the first await. The effect's dependency collection stops the moment the synchronous part of the callback returns. This code looks fine and is broken:
const category = ref('beauty')
const items = ref([])
watchEffect(async () => {
const res = await fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=30')
const data = await res.json()
// โ category.value is read AFTER the first await โ NOT tracked
items.value = data.filter(p => p.category === category.value)
})
Measured: 1 fetch on mount, 9 beauty products render. Click a button that flips category to 'toys' โ nothing happens. Still 1 fetch total, the list still showing the 9 beauty products, and the console is completely clean. This is the shape behind most "watchEffect not triggering on ref change" searches: the ref changed, the effect just never subscribed to it.
The fix is mechanical โ read everything you depend on before you await (usually while building the URL):
watchEffect(async () => {
const cat = category.value // โ
read synchronously โ tracked
const res = await fetch(`https://mockbird.mockbird.workers.dev/m/demo/products?category=${cat}`)
items.value = await res.json()
})
Measured: 1 fetch on mount, 2 after the click โ and the server does the filtering, which is what you wanted anyway.
Second trap: an async effect that writes a ref it also reads. Four innocent-looking lines:
const items = ref([])
watchEffect(async () => {
// reads items.value.length synchronously โ tracked
const res = await fetch(`โฆ/products?limit=5&page=${(items.value.length % 3) + 1}`)
items.value = await res.json() // writes the same ref, after the await
})
Measured: 45 fetches by 2 seconds, 112 fetches by the 5-second mark, still climbing, with zero console warnings or errors โ we patched both console.warn and console.error to be sure. Vue 3.5.43 emits nothing. Your only symptoms are a warm laptop and a network tab that scrolls.
Why it loops: the length read happens in the synchronous phase, so the effect subscribes to the ref. The write happens after the await โ and each response is a new array identity, so the "did it change" check always says yes, which re-triggers the effect, which fetches again, forever. Even when the length lands on the same number, the array is a different object.
Here's the cruel part. The synchronous version of read-and-write-the-same-ref is guarded:
const n = ref(0)
watchEffect(() => {
n.value = n.value + 1 // no await anywhere
})
Measured: runs exactly once, renders count: 1, no loop, no warning. Vue deliberately doesn't let an effect re-trigger itself during its own synchronous run. So you try the pattern once without await, see it behave, and conclude the async version is fine too. It isn't: once the callback suspends at an await, the synchronous run is over, the guard is gone, and every later write is treated as an external change. The guard's scope is the sync run โ not the whole async function.
Escape hatches when you genuinely need to read state you also write: toRaw(items.value) for an untracked read, or restructure so the effect only writes and a computed does the reading.
Third trap โ the classic. Responses don't arrive in the order you sent them:
watchEffect(async () => {
const p = page.value
const res = await fetch(`โฆ/products?page=${p}&limit=5`)
items.value = await res.json() // โ last response to ARRIVE wins
})
We made page 2 slow (?mock_delay=1500 โ one query param on the mock, no test scaffolding) and clicked page 2 โ page 3 quickly. Measured result: the header says page: 3, the list shows page 2's rows โ first item "Work Service Area Case" is a page-2 record. 3 fetches, 0 aborts, no error anywhere. The slow response landed last and clobbered the fast one.
onWatcherCleanup + AbortController (and where it must go)Vue 3.5 added onWatcherCleanup โ a callback that runs right before the effect re-runs. Wire it to an AbortController and the stale request dies instead of clobbering:
import { watchEffect, onWatcherCleanup } from 'vue'
watchEffect(async () => {
const p = page.value
const ctrl = new AbortController()
onWatcherCleanup(() => ctrl.abort()) // โ
registered BEFORE any await
try {
const res = await fetch(`โฆ/products?page=${p}&limit=5`, { signal: ctrl.signal })
items.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})
Same slow-page-2 drill, measured: header page: 3, page-3 rows, exactly 1 aborted request, zero errors. The page-2 fetch was cancelled mid-flight the moment page 3 was requested.
Placement matters. Move the onWatcherCleanup call after the await and Vue warns, verbatim:
[Vue warn] onWatcherCleanup() was called when there was no active watcher to associate with.
โ and the cleanup silently never registers (we measured: no abort happens, the race is back). Same rule as dependency tracking: the watcher is only "active" during the synchronous part. Register cleanup before you await, always. (Pre-3.5, the same fix is the onCleanup third argument of watch โ same placement rule.)
watch fetches zero times on mount (and the one-word fix)Switching from watchEffect to watch for explicit deps has its own newcomer trap โ watch is lazy:
watch(page, async (p) => {
const res = await fetch(`โฆ/products?page=${p}&limit=5`)
items.value = await res.json()
})
Measured: 0 fetches on mount โ the screen sits on "items: 0" until the user changes something. The callback only runs on changes. The fix is one option:
watch(page, async (p) => { โฆ }, { immediate: true })
Measured: 1 fetch on mount, 5 items rendered. Bonus of watch over watchEffect for fetching: the dep list is explicit, so the ยง2 after-await trap can't bite, and the callback receives an onCleanup argument for the ยง6 pattern.
Your skeleton renders for 80ms against localhost โ too fast to check. One query param makes it real:
const loading = ref(false), error = ref(''), items = ref([])
watchEffect(async () => {
loading.value = true; error.value = ''
try {
const res = await fetch('โฆ/products?limit=5&mock_delay=2000')
if (!res.ok) throw new Error(`HTTP ${res.status}`) // fetch does NOT reject on 500s
items.value = await res.json()
} catch (e) { error.value = e.message }
finally { loading.value = false }
})
Measured: with ?mock_delay=2000 the v-if="loading" branch is on screen at the 300ms check; the list arrives at ~2s. Swap in ?mock_status=500 and the error branch renders "HTTP 500" โ only because of the res.ok check; plain fetch resolves fine on a 500 and your catch block never fires without it. Full recipe collection: testing loading & error states.
?mock_seq scripts a per-client response sequence โ fail, fail, succeed โ so you can watch your retry path actually run:
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3&mock_seq=503,503,200&mock_seq_key=$(date +%s)"
Wired into the watchEffect with a tick ref as the retry trigger, measured: click retry twice โ UI shows HTTP 503, HTTP 503, then the list โ exactly 3 fetches, in order, every run (the fresh mock_seq_key gives each session its own counter). The x-mockbird-seq response header tells you where in the sequence you are.
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset":"ecommerce"}'
Returns a project id + admin key; your base URL is /m/<id> with products, orders, customers and reviews seeded โ same filters, same simulation params, plus writes that persist. Or one click, or import an OpenAPI spec / db.json / CSV / Postman collection / HAR and mock your exact shapes. Anonymous projects: no signup, 10 per IP per day.
mock_jitter, mock_chaos โ random latency and probabilistic failures, for soak-style manual QA.mock_ratelimit โ deterministic 429s with Retry-After, for backoff code./auth/login, protected mode for 401 paths (guide)./graphql, plus generated openapi.json and types.ts?format=zod for typed clients.| Tool | What it's great at | Trade-off vs this setup |
|---|---|---|
VueUse useFetch | Drop-in composable: refs for data/error/loading, abort-on-refetch handled for you | The right call for many apps โ it packages ยง5โยง8 correctly. It manages the request; it doesn't give you an API to call or a way to script 503s. Point it at Mockbird and drill both halves. |
| TanStack Query (Vue) / Pinia Colada | Production-grade caches: dedup, revalidation, retries, devtools | They industrialize everything this guide hand-rolls โ prefer them once an app grows real data needs. The measurements here are for the bare-composition-API case where you're on your own (and for understanding what those libraries save you from). |
Nuxt useFetch/useAsyncData | Framework-managed fetching with SSR hydration | If you're in Nuxt, use it โ it owns the promise lifecycle and most of these traps can't happen. Bare Vite + Vue SPAs are where this guide applies. (Nuxt guide.) |
| MSW | In-browser request interception; tests run offline | Mocks live in your bundle and every teammate's setup. A hosted mock is one URL shared by the app, tests, CI, and a phone on your desk โ failure modes are query params, not handler code. |
Reaching for VueUseโs useFetch instead of hand-rolled effects? Its defaults cut both ways โ the computed URL that never refetches, the race it aborts for you, and the error body it throws away: VueUse useFetch, measured.
Fetching through Pinia instead of component-level effects? Same measuring stick, different traps โ the destructure that freezes your UI, the 469-fetch getter storm, the $reset that throws: Pinia stores + fetch, measured.
Bias disclosure: this comparison is written by the Mockbird side. The measurements, though, are just measurements โ rerun them in your own Chrome with the snippets as written.