← blog
NuxtSeptember 13, 2026 · 14 min read

useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug

Learn how Nuxt's useAsyncData and useFetch generate cache keys, how dedupe (cancel vs defer) really works, and why wrapper composables silently share data.

Parsa Jiravand · Frontend engineer · building bestpractic
useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug
  1. 1Nuxt useState vs ref(): Why Server State Leaks Across Users14 min
  2. 2useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bugyou are here

Two product cards on the same page, fetched with two different IDs, and both show product #1's data. No error in the console, no failed request in the network tab — useAsyncData did exactly what you asked it to do. The bug is that you didn't ask it what you thought you did.

This is one of the most common "it works everywhere except in this one wrapper" bugs in Nuxt, and it comes from a single, easy-to-miss fact: useAsyncData's cache key is not derived from the data you're fetching — it's derived from where in your source code you call it.

By the end of this article you'll be able to:

  • Explain how useAsyncData and useFetch generate a cache key when you don't supply one, and why that breaks inside a wrapper composable
  • Read and choose between dedupe: 'cancel' and dedupe: 'defer' for a given piece of UI
  • Trace how a key connects a server-rendered fetch to the payload the client hydrates from
  • Use refresh(), clearNuxtData(), and the watch option to control exactly when a re-fetch happens
  • Give every dynamic fetch an explicit, correct key, on reflex

You've used useAsyncData or useFetch in a Nuxt page or component at least once. You don't need to have hit this bug yet — the article builds the mental model from the first principles of what a "key" is for.

This article is written against Nuxt 4.5.x (verified against the nuxt package's npm dist-tags in September 2026; the latest patch at the time of writing is 4.5.2, with Nuxt 3 having reached end-of-life on July 31, 2026). Code and directory paths use the Nuxt 4 app/ convention (app/composables/, app/pages/); if your project still uses the flat Nuxt 3 layout (composables/, pages/ at the root), everything below works unchanged — only the folder location differs.

Say you build a small wrapper composable to keep your components clean:

TypeScript
1
2
3
4
5
// app/composables/useProduct.ts export function useProduct(id: Ref<string> | string) { return useAsyncData(() => $fetch(`/api/products/${unref(id)}`)) // ^ no key passed — looks harmless }

And you use it twice on the same page:

Vue
1
2
3
4
5
6
7
8
9
10
<script setup lang="ts"> const { data: featured } = useProduct('sku-101') const { data: related } = useProduct('sku-204') </script> <template> <ProductCard :product="featured" /> <ProductCard :product="related" /> <!-- both cards render sku-101 --> </template>

Both <ProductCard>s show the same product. There's no thrown error, because nothing went wrong at the network level — both requests may even have fired correctly. The bug is upstream of the network: Nuxt handed both calls the same cache entry, so the second call's result overwrote (or was skipped in favor of) the first's.

The instinct here is to blame $fetch, the API, or a race condition. It's none of those. It's the key.

The mental model: every useAsyncData (and useFetch, which is useAsyncData with the URL folded in) call is backed by an entry in a shared, page-level store keyed by a string. Two calls with the same key are, as far as Nuxt is concerned, the same fetch — they share one data ref, one pending ref, one error ref, and one in-flight request. Two calls with different keys are entirely unrelated, even if they happen to call the exact same URL.

The key is not a label you attach to data you already have. It's how Nuxt decides whether two calls in your app are asking for the same thing. If you don't supply one, Nuxt has to invent one — and it does that with a build-time compiler step that reads the file and line number where useAsyncData() is written in your source, and turns that location into a deterministic auto-key.

That's the trap: the compiler sees the call site inside useProduct.ts, not inside your page. useProduct('sku-101') and useProduct('sku-204') both resolve to a call to useAsyncData() on the same line of the same file — the body of the wrapper. So both get the same auto-generated key, regardless of which ID was passed in. The wrapper hid the one piece of information Nuxt needed to tell the two calls apart.

Written directly in a page or component (no wrapper), an explicit key removes all ambiguity:

Vue
1
2
3
4
5
6
7
8
9
<script setup lang="ts"> const route = useRoute() // Key concept: the key says "this is product-<id>", nothing else does. const { data: product, status } = useAsyncData( `product-${route.params.id}`, () => $fetch(`/api/products/${route.params.id}`) ) </script>

useFetch is the shorthand for the common case — a call that's really just "GET this URL, reactively":

Vue
1
2
3
4
5
6
7
8
<script setup lang="ts"> const route = useRoute() // useFetch derives its own key from the method, URL, and reactive options — // you rarely need to pass one explicitly here, because the URL already // carries the id. const { data: product } = useFetch(() => `/api/products/${route.params.id}`) </script>

Key concept: useFetch's implicit key already bakes in the URL, so a reactive URL (an arrow function, as above) naturally produces a different key per product. That single detail is why useFetch feels safer than useAsyncData by default — its auto-key is derived from something that actually varies with your data, not from source position.

Call useAsyncData directly in a page, with no wrapper in between, and the file-and-line auto-key works fine — every distinct call site in your codebase is, by definition, a distinct line. The trap only appears once you factor a useAsyncData call into a shared function that different callers invoke with different arguments — exactly the "let's DRY this up into a composable" instinct that Vue and Nuxt otherwise reward.

TypeScript
1
2
3
4
5
6
7
// app/composables/useProduct.ts — the version that actually works export function useProduct(id: Ref<string> | string) { return useAsyncData( `product-${unref(id)}`, // key now varies with the argument () => $fetch(`/api/products/${unref(id)}`) ) }

The fix is one line: pass a key that's built from the argument, not the call site. The same rule applies to any composable you write around useAsyncData, useLazyAsyncData, or a hand-rolled data-fetching hook — the moment a useAsyncData call is wrapped in a function that more than one place will call with different inputs, that key must be explicit and must include those inputs.

Keys decide whether two calls are the same fetch. dedupe decides what happens when the same key is asked for again while a request for it is still in flight. There are exactly two values, and Nuxt defaults to 'cancel':

  • dedupe: 'cancel' (the default) — abort the in-flight request for this key and start a fresh one. Correct when only the latest request matters: a search box that refetches on every keystroke, a filter panel, anything where an older in-flight response would be stale by the time it arrives.
  • dedupe: 'defer' — if a request for this key is already in flight, don't start a second one; the new call reuses the pending request instead. Correct when the request is expensive or has side effects and firing it twice for the same key is simply wasteful (two components in the same render both asking for the same key at the same time, a button a user might double-click).
TypeScript
1
2
3
4
5
6
// A typeahead: only the newest keystroke's result should win. const { data: results } = useAsyncData( () => `search-${query.value}`, () => $fetch('/api/search', { query: { q: query.value } }), { watch: [query], dedupe: 'cancel' } )
TypeScript
1
2
3
4
5
6
7
// An expensive report two dashboard widgets both need at once: // one request in flight is enough for both. const { data: report } = useAsyncData( 'quarterly-report', () => $fetch('/api/reports/quarterly'), { dedupe: 'defer' } )

Key concept: dedupe is not debouncing. Debouncing delays starting a call; dedupe decides what happens to calls that have already started for the same key. You often want both — debounce the keystroke, then let dedupe: 'cancel' handle any request that still overlaps.

During SSR, Nuxt resolves your page's useAsyncData/useFetch calls and serializes the results into a payload — a plain-data snapshot sent to the browser alongside the HTML, keyed by exactly the same strings you've been reading about. On the client, hydration doesn't re-run your fetches from scratch: for each key, Nuxt's getCachedData step looks in that payload first. If the key is present, the cached value is used immediately and the handler function never runs on the client at all.

This is the other reason a duplicated auto-key is dangerous, not just cosmetically wrong: it means the server also only ran one fetch for what you thought were two different pieces of data, so the payload only ever contained product #1's response in the first place. The bug isn't purely a client-side rendering glitch — the wrong data was fetched once, on the server, and faithfully shipped to the browser.

(If server-side state feels shaky in general, the previous episode in this series, Nuxt useState vs ref(): Why Server State Leaks Across Users, covers the sibling bug — module-scope state shared across requests rather than across calls on the same page. Useful background, not required reading.)

useAsyncData returns more than data: status, pending, error, refresh (an alias, execute, does the same thing), and clear.

Vue
1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts"> const { data: product, refresh, clear } = useAsyncData( `product-${route.params.id}`, () => $fetch(`/api/products/${route.params.id}`) ) </script> <template> <button @click="refresh()">Reload this product</button> <button @click="clear()">Reset</button> </template>
  • refresh() / execute() — re-runs the handler for this call's key and updates data in place. This is the correct way to force a re-fetch from inside the component that owns the call.
  • clear() — resets data to undefined (or the configured default), error to undefined, and status to idle, without refetching.
  • refreshNuxtData(key?) / clearNuxtData(key?) — the same two operations, callable from anywhere, by key, when you don't have a reference to the original composable call (a "save" action in one component that should invalidate a list rendered in another).
  • watch: [...] — an array of reactive sources; when any of them changes, Nuxt automatically calls refresh() for you. This is what makes Stage 3's search example refetch on every keystroke without a manual watch() block of your own.

Key concept: changing the key and adding a watch source are two different ways to make a fetch reactive, and they're not interchangeable. A changing key gives you a distinct cache entry per value (useful when you want to keep old results around, like cached pages of a paginated list). A watch source refetches the same entry in place, overwriting it (useful when you only ever care about the current value, like a live search).

  • Route params without a param-derived key. useAsyncData('product', ...) on a dynamic [id].vue page reuses one cache entry across every product route the user navigates to client-side — the classic "stale data flashes for a moment on navigation" bug. Always fold the param into the key.
  • useLazyAsyncData doesn't block navigation, but it's the same key machinery. A lazy call that shares a key with a non-lazy call elsewhere on the page still participates in the same dedupe and cache entry — "lazy" only changes whether Nuxt awaits it before rendering, not how its key behaves.
  • Two components requesting the same key on the same page is often intentional, not a bug — it's exactly how Nuxt avoids two network round-trips for data two widgets both need. The failure mode in this article is the opposite: keys colliding when you didn't want them to.
  • dedupe: 'defer' on a request with side effects can surprise you. If the "duplicate" call actually needed to trigger a fresh side effect (an analytics ping baked into the handler, say), deferring silently skips it. Keep handlers free of side effects that must run every time they're called.
  • SSR + hydration mismatch from a key that differs between server and client. If your key computation depends on something only available client-side (e.g., window-derived state), the server and client will disagree on the key, and the client will refetch instead of hydrating from the payload — usually harmless, but worth knowing so it doesn't look like a hydration bug elsewhere.

  • Reach for an explicit key the moment a useAsyncData call sits inside a composable, a loop, or anywhere more than one logical "thing" could call it. The rule of thumb: if the data being fetched varies, the key must vary with it.
  • Build the key from the same values the handler uses — a template literal with every dynamic input (\product-${id}`, not just 'product'`) is the whole fix, every time.
  • Prefer useFetch for the plain "reactively GET this URL" case — its implicit key already includes the URL, so you get the safety of an explicit key for free.
  • Choose dedupe by cost and freshness, not by habit. Default ('cancel') for anything driven by fast user input where only the latest answer matters; 'defer' for expensive or side-effect-bearing calls that multiple simultaneous callers can safely share.
  • Invalidate by key, not by reload. Reach for refresh()/refreshNuxtData() and clear()/clearNuxtData() before reaching for a full page reload or a manually toggled key prop to force a remount.

Because both useAsyncData calls resolved to the same auto-generated key — most often because both went through the same line of a shared wrapper composable. Nuxt didn't fail; it correctly treated two calls with the same key as one fetch.

Less often, because useFetch's default key already incorporates the request's method, URL, and reactive options — so a URL that varies (like /api/products/${id}, written reactively) naturally produces different keys. Wrapping useFetch in a composable is still worth double-checking, but the failure mode is narrower.

No. Debouncing controls when a call is allowed to start. dedupe controls what happens when a call for a key that's already in flight comes in — cancel the old one and start fresh ('cancel'), or reuse the one already running ('defer').

Call refreshNuxtData('your-key') (or clearNuxtData('your-key') to also reset the state) from wherever the triggering action lives — you don't need a reference to the original useAsyncData call, only its key.

Not for this specific bug — key collisions only happen when the same call site runs more than once with different intent. But it's worth building the reflex anyway, because "once per page" has a way of becoming "also once inside a composable" as an app grows.

TaskCodeNotes
Explicit keyuseAsyncData('product-' + id, fn)Always do this inside any shared composable.
Implicit key (useFetch)useFetch(() => \/api/products/${id}`)`Key derives from method + URL + options; keep the URL reactive.
Refetch on latest input only{ watch: [query], dedupe: 'cancel' }Default dedupe; cancels the stale in-flight request.
Share one in-flight request{ dedupe: 'defer' }For expensive/side-effect-free calls two callers might trigger at once.
Manual refetch, same componentconst { refresh } = useAsyncData(...); refresh()Also aliased execute().
Reset without refetchingclear()Sets data/error back to empty, status to idle.
Refetch from elsewhere, by keyrefreshNuxtData('product-101')No reference to the original call needed.
Clear from elsewhere, by keyclearNuxtData('product-101')Same idea, resets instead of refetching.
Auto-key sourcefile + line of the useAsyncData() callBreaks inside wrappers called from multiple places — pass an explicit key there.

  • A useAsyncData/useFetch key is the fetch's identity: same key means same cached entry, same in-flight request, same everything.
  • Nuxt auto-generates a key from the call's file and line number when you don't supply one — which silently collapses to one shared key when the call lives inside a wrapper composable called from multiple places.
  • dedupe: 'cancel' (default) and dedupe: 'defer' solve different problems — freshness versus avoiding redundant work — and picking the wrong one either serves stale data or wastes requests.
  • The key also decides what the server's payload hands the client to hydrate from, so a key bug is a server-side bug, not just a rendering glitch.
  • refresh/execute, clear, refreshNuxtData, and clearNuxtData give you precise control over when a fetch actually happens — reach for them before a manual reload.

Next time two pieces of UI that should be independent start mirroring each other's data, the network tab won't show you why — the fix is almost always one string, built from the right variables, passed as the second-to-last thing you'd suspect: the key.

Runs right in your browser — poke at it and watch the concept react live.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

What's the last place a wrapper composable in your own codebase might be hiding a shared key right now? Worth a five-minute grep before it becomes a bug report.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.