Nuxt's useAsyncData and useFetch are the built-in answer to data fetching for one of Vue's biggest frameworks โ keyed, deduped, SSR-aware, and close enough to a cache layer that people skip TanStack Query entirely. So we measured them the way we've measured everything else in this series. The results split cleanly down the middle of the two composables' names: useFetch quietly fixed the trap its sibling still has, the reactive-URL storm that TanStack Query refuses to ignite does ignite here, and Nuxt 4 made a cache decision โ purge on unmount โ that inverts the "instant back-navigation" behavior cache layers are usually chosen for.
Everything below was reproduced and measured in a real Chrome on Nuxt 4.5.2 + Vue 3.5.43 (SPA mode, ssr: false) with an instrumented window.fetch and an abort counter. Two full runs produced identical numbers (retry timings ยฑ4 ms). useFetch is sugar over useAsyncData(key, () => $fetch(url)), and $fetch is ofetch 1.5.1 โ two of the findings below (the double-fired GET, the retried 500) are ofetch defaults that most Nuxt users have never been told about.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:3000, StackBlitz, CI โ anywhere. When you want your own schema it's one curl or one click โ see ยง16.
<script setup>
const { data, status, error } = await useFetch(
'https://mockbird.mockbird.workers.dev/m/demo/products?limit=5'
)
</script>
Measured: 1 fetch, 5 rows, status: "success", clean console. Note the await: in Nuxt an awaited useFetch suspends the whole component โ which is a feature or a footgun depending on what wraps it (ยง3 and ยง11).
lazy: true = you own the loading UISlow the response to 1.5 s (?mock_delay=1500) and the awaited form renders nothing โ the component sits behind its <Suspense> fallback until the data lands. Measured: fallback visible through the full delay, rows only after. That's the intended design, but nobody chooses a blank screen on purpose for a 1.5 s endpoint.
const { data, status } = useFetch(url, { lazy: true }) // don't await
Measured with the same 1.5 s delay: the component paints immediately โ status: "pending", 0 rows, your own v-if="status === 'pending'" spinner โ and the rows arrive at ~2.3 s (SPA boot + delay). 1 fetch either way. If you can't see your skeleton, you can't test it; a mock_delay URL is the fastest way to hold one on screen.
useFetch fixed it, useAsyncData still has itWrap your fetch in a composable โ the thing every Nuxt codebase does by week two โ and call it twice with different arguments from one component:
function useProducts(limit) {
return useAsyncData(() => $fetch(`${API}/products?limit=${limit}`)) // โ no key
}
const a = useProducts(2)
const b = useProducts(5)
Measured: 1 fetch, and both callers get limit=2's data โ a renders 2 rows, b renders the same 2 rows, no warning, no error, nothing in the console. useAsyncData's auto-generated key is derived from the call site (file + position), so two invocations of the same composable line are, as far as the cache is concerned, the same query โ and the second silently receives the first's data. This is the silent-collision bug people file against Nuxt repeatedly, and it's invisible until someone notices the wrong list rendering.
The same composable shape with useFetch, measured: 2 fetches, 2 rows and 5 rows, correct data on both โ because useFetch's auto-key incorporates the URL. That asymmetry is the whole story: useFetch(url) is safe to leave keyless; bare useAsyncData in a composable must always be given an explicit key that includes its arguments:
useAsyncData(`products-${limit}`, () => $fetch(url))
Two sibling components mounted together, both using the same explicit key and the same URL. TanStack Query measured 1 shared fetch here. Nuxt, measured: 2 fetches and 1 abort โ the second component's mount cancels the first's in-flight request (the default dedupe: 'cancel' applies across components sharing a key) and issues its own. Both siblings render, and with different keys it's a plain 2 fetches, 0 aborts. Same-key mounting in Nuxt is last-caller-wins request replacement, not TanStack-style request sharing โ fine for correctness, worth knowing when you count requests in a test.
const page = ref(1)
const { data } = useFetch(() => `${API}/products?_page=${page.value}&_limit=3`)
Click "next": measured exactly 1 refetch, first row changes, 0 aborts, no manual watch. Passing a function (or computed) as the URL makes the key reactive โ this is the good half of the reactivity design. The bad half is ยง9.
dedupe: 'cancel' kills the loserThe race that breaks naive implementations across this series: request a slow page (answers in 1.5 s), switch to a fast one (answers in 100 ms) 300 ms later. Last-to-resolve wins and stale rows paint over the new header. The mock decides who's slow, deterministically:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?sortBy=id&order=asc&mock_delay=1500"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?sortBy=id&order=desc&mock_delay=100"
With a reactive URL, measured: 2 fetches, 1 abort โ the slow request is cancelled mid-flight the moment the URL changes โ final rows are the fast response's (first id 30), and nothing ever flips back. The race can't be lost: changing the URL changes the key, and the old request is torn down with it.
One caveat for people reading the docs: we also measured dedupe: 'defer' expecting the slow request to survive. Identical result โ still 1 abort, still fast-wins. The dedupe option only arbitrates same-key concurrent calls; a reactive URL switch is a key change, and the teardown happens regardless.
refresh() spam: N requests, last one winsCall refresh() five times in a tight loop while a 600 ms request is in flight. Measured: 5 new fetches, 4 aborts โ each refresh cancels the previous โ ending in success with correct rows. So a refresh button that users hammer degrades gracefully (no stale-overwrite), but every click is a real request to your API; debounce if that matters.
Put an always-changing value inside the reactive URL function โ a ticking store value, a Date.now() cache-buster, anything driven by an interval:
const tick = ref(0)
setInterval(() => tick.value++, 250)
const { data } = useFetch(() => `${API}/products?limit=3&t=${tick.value}`)
Measured: 5 fetches by the 2-second mark, 17 fetches by 5 seconds, 0 aborts, no warning โ one request per tick, forever, silently. This is the exact scenario TanStack Query shrugged off a guide ago (inline-object keys, client-in-render: 1 fetch each) โ TQ's structural key hashing and observer model absorb unstable inputs; Nuxt's reactive key faithfully refetches on every change, because that's what reactivity means. Neither is wrong, but only one of them turns a sloppy dependency into a request storm. If a value shouldn't cause refetches, keep it out of the URL function and read it inside the handler instead.
Serve a deterministic status script โ 503, 503, then real data โ on a fresh key:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_seq=503,503,200&mock_seq_key=run1"
Point default useFetch at it and measure the network: 2 requests, ~30 ms apart, then status: "error". That's ofetch's default: failed GETs are retried once, immediately (retry: 1, retryDelay: 0) โ and the retry list includes 500. We confirmed with a plain ?mock_status=500: 2 fetches for one "single" failed request. Your error logs double-count, your rate limits burn twice as fast, and a non-idempotent-but-mislabeled GET runs twice. Nobody chose this; it's inherited from the fetch wrapper underneath.
Tuned the other way, retry is genuinely useful โ retry: 3, retryDelay: 500 against the 503,503,200 script measured 3 requests at 0 / ~533 / ~1055 ms, third one succeeds, component recovers to success with rows on screen. Use a fresh mock_seq_key per test run so each run gets the sequence from the top (the response carries x-mockbird-seq: n/3 so you can assert each attempt).
When the error does land, the shape is good: error.value.statusCode is 500, error.value.data is your API's parsed JSON error body, and data.value stays undefined โ not null, which matters if you're checking data.value === null anywhere.
res.ok and the component hangs on its Suspense fallback forever$fetch throws on HTTP errors โ but plenty of code passes plain fetch to useAsyncData, and plain fetch doesn't reject on a 500:
const { data } = await useAsyncData('naive',
() => fetch(url).then(r => r.json()) // โ no res.ok check
)
Against ?mock_status=500, measured: status is "success", error is undefined, and data is {"error":"simulated 500 error (mock_status)"} โ the error body, cached as if it were products. Then the template's data.map(โฆ) throws $setup.data.map is not a function as an unhandled render error โ and because the component is async, it never resolves past its <Suspense> boundary: the fallback stays on screen forever. Same root trap TanStack Query has, with a worse outcome โ TQ gives you a render crash an error boundary can catch; Nuxt gives you a permanent loading state that looks like a slow network. Either use $fetch (it throws properly, and ยง10's error shape applies) or keep the if (!r.ok) throw line. Drill every branch on demand:
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500" # any code: 401, 403, 429โฆ
Load a keyed component, unmount it, remount it 300 ms later. A cache layer's promise is instant rows on return. Measured in Nuxt 4: the loading fallback is visible again ~150 ms after remount, and a second fetch fires. Nuxt 4 changed the default (purgeCachedData) to drop a key's cached payload when its last consumer unmounts โ good for memory on long sessions, but it means there is no back-navigation cache by default: every tab switch, every v-if toggle, every route return is a fresh loading state and a fresh request. Contrast TanStack Query, which serves cached rows instantly and revalidates in the background. If you want the old behavior: experimental: { purgeCachedData: false } in nuxt.config, or accept the refetch and make the fallback cheap.
| Option | Measured |
|---|---|
watch: [flag] | bump the ref โ 1 extra refetch of the same URL (1โ2 fetches) |
transform: rows => โฆ | 1 fetch; data is the transform's output, not the raw body |
immediate: false | status is "idle" (not pending), 0 fetches; execute() โ 1 fetch, success |
clearNuxtData(key) | data โ undefined, status back to "idle", list empties, no request; refresh() reloads |
The "idle" status is easy to forget: a component guarding only on pending renders its success branch against undefined data both before execute() and after clearNuxtData.
Seeded dev data means the "No products yet" branch ships untested. Pin a request to an empty snapshot โ live data untouched:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty" # โ []
Measured in the harness: 1 fetch, status: "success", data is [], the empty-state line renders. data?.length === 0 is the honest check โ !data.value conflates empty with ยง13's idle/cleared states.
| Behavior to test | One URL |
|---|---|
| Loading states, race choreography | ?mock_delay=800 |
| Every error branch | ?mock_status=500 (or 401, 429โฆ) |
| Retry recovery | ?mock_seq=503,503,200&mock_seq_key=run1 |
| Empty state | ?mock_snapshot=empty |
| Flaky-network resilience | ?mock_chaos=0.3 |
| Slow + jittery | ?mock_delay=500&mock_jitter=1000 |
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset": "ecommerce"}'
That returns a live base URL with seeded products, orders, customers, reviews โ full CRUD (writes persist), filters, pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag above. Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.
Written by the Mockbird maker โ bias disclosed. Nuxt's composables measure like what they are: reactivity-first, cache-second. The wins are real โ the race is unloseable (ยง7), refresh spam self-cancels (ยง8), reactive pagination needs zero ceremony (ยง6) โ and the traps are the mirror image: reactive keys faithfully amplify unstable inputs into a storm (ยง9), bare useAsyncData auto-keys collide silently in composables (ยง4), and Nuxt 4's purge-on-unmount means the cache layer doesn't actually cache across remounts by default (ยง12). The two ofetch inheritances โ every failed GET fired twice, 500s retried (ยง10) โ deserve to be in the Nuxt docs in bold. If you're already reaching for TanStack Query inside Nuxt, these measurements are the argument either way. Measurements taken Sep 24, 2026 on Nuxt 4.5.2, Vue 3.5.43, ofetch 1.5.1, SPA mode (ssr: false), client-side rendering; two runs, identical numbers (retry timings ยฑ4 ms). SSR/hydration behavior is its own topic โ these numbers are the client story. Related: mock a REST API for Nuxt (tutorial + the $fetch.raw headers gotcha), TanStack Query, measured, VueUse useFetch, measured, raw watchEffect fetching, measured, Pinia Colada, measured.