โ† All guides

Nuxt useAsyncData & useFetch, measured โ€” the silent composable collision, the storm TanStack doesn't have, and the unmount that purges your cache

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.

1. Try it in 10 seconds (no signup)

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.

โšก Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. The good path

<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).

3. Awaited = blocking; lazy: true = you own the loading UI

Slow 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.

4. The auto-key trap: useFetch fixed it, useAsyncData still has it

Wrap 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))

5. Two components, same key: not shared โ€” cancelled and re-fetched

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.

6. Reactive URLs: the pager that just works

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.

7. The pager race is unloseable โ€” dedupe: 'cancel' kills the loser

The 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.

8. refresh() spam: N requests, last one wins

Call 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.

9. The storm TanStack doesn't have, Nuxt does

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.

10. The default retry fires every failed GET twice โ€” 500s included

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.

11. Skip 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โ€ฆ

12. Nuxt 4 purges the cache on unmount: every remount is a flash + refetch

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.

13. The rest of the toolkit, measured quickly

OptionMeasured
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: falsestatus 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.

14. The empty state you never see in dev

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.

15. The drill kit

Behavior to testOne 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

16. Your own backend in one curl

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.

17. Honest notes

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.

โ† All guides