What is the difference between useFetch and useAsyncData in Nuxt?
useFetch is a shorthand for HTTP requests with automatic cache keying by URL, while useAsyncData runs any async code and caches the result under a key you provide manually.
Theory
TL;DR
useFetchis like a restaurant app: give it a URL, it tracks the request and caches the result automatically.useAsyncDatais cooking yourself: full control over every step, you name the recipe for caching.- Main difference:
useFetchauto-generates a cache key from the URL and options;useAsyncDatarequires you to pass a string key. - Single API endpoint?
useFetch. Filtering, joining, or multiple calls?useAsyncData. - Decision shortcut: if you can write it as one URL, use
useFetch.
Quick example
<!-- useFetch: cache key is '/api/users' -->
<script setup>
const { data: users, pending } = await useFetch('/api/users')
</script>
<!-- useAsyncData: manual key 'active-users', custom logic -->
<script setup>
const { data } = await useAsyncData('active-users', () =>
$fetch('/api/users').then(users => users.filter(u => u.active))
)
</script>useFetch caches the raw response under the URL. useAsyncData caches the filtered result under 'active-users'. Same source, different outputs, different keys.
Key difference
useFetch hashes the URL and request options into a unique cache key automatically, so it reacts to route changes without extra setup. useAsyncData uses whatever string you pass as the key, which gives room for arbitrary async code but puts the responsibility for key uniqueness on you. If two pages share the same key with different data shapes, the last fetch wins and you get stale output.
When to use
- Fetching a single endpoint directly:
useFetch('/api/posts') - Dynamic route data that changes with the URL:
useFetch(reacts automatically) - Filtering or transforming the response:
useAsyncData(wrap$fetchin your logic) - Multiple parallel requests:
useAsyncDatawithPromise.allinside the handler - Client-only data like WebSocket state:
useAsyncDatawithserver: false
Comparison table
| Feature | useFetch | useAsyncData |
|---|---|---|
| Cache key | Auto (URL + options hash) | Manual (required string) |
| Handler | URL string | Any async function |
| Reactivity | Auto on route change | Manual via key |
| Default fetcher | $fetch | None, provide your own |
| Lazy mode | Built-in option | Built-in option |
| SSR payload | Auto-included | Auto-included if key is unique |
| Best for | Quick API calls like /api/posts | Filter, join, or complex logic |
How caching works
Both composables register async tasks with Nuxt's SSR renderer, suspend page render until the handler resolves, and serialize results into the HTML payload for hydration. useFetch passes the result of hash(url + options) as the key into useNuxtApp().payload.data. useAsyncData injects your string directly into the same store. On the client, if the key already exists in the payload, no second request fires.
Common mistakes
Missing key in useAsyncData
// Wrong - key is required in Nuxt 3
const { data } = await useAsyncData(async () => $fetch('/api/posts'))
// Correct
const { data } = await useAsyncData('posts', async () => $fetch('/api/posts'))Static key for dynamic data
// Wrong - id changes but key stays 'posts', so you always get the first result
const { data } = await useAsyncData('posts', () => $fetch(`/api/posts/${id}`))
// Correct - key changes with id
const { data } = await useAsyncData(`posts-${id}`, () => $fetch(`/api/posts/${id}`))This is the mistake I see most often in code reviews. People notice data does not update when navigating between records, add a watch, and still wonder why it breaks. The key was the issue the whole time.
Using useFetch when you need to transform the response
// Works, but data is the raw response with no filtering
const { data } = await useFetch('/api/search', { body: { q: 'nuxt' } })
// Better when you need to parse or filter
const { data } = await useAsyncData('search-nuxt', () =>
$fetch('/api/search', { method: 'POST', body: { q: 'nuxt' } })
.then(res => res.results.filter(r => r.published))
)Real-world usage
- Nuxt Content:
useAsyncData('content', () => queryContent().find()) - Nuxt Auth (sidebase):
useFetch('/api/auth/me')for session data - Supabase + Nuxt:
useAsyncData(posts-${userId}, () => supabase.from('posts').select())for row-level security - Cache invalidation after mutation:
refreshNuxtData('key')to refetch,clearNuxtData('key')to drop the value
Follow-up questions
Q: What happens if two components use useAsyncData with the same key but different handlers?
A: The second call reuses the cached result from the first. Intentional for shared data, but if the handlers differ you get wrong output. Always use unique keys per data shape.
Q: Can useFetch handle POST requests?
A: Yes. Pass method: 'POST' and body as options. But if you also need to transform the response, switching to useAsyncData keeps the intent clearer.
Q: What does server: false do in useAsyncData?
A: It skips the handler during SSR and only runs it on the client. Use this for data that depends on browser APIs or should not land in the HTML payload, like live dashboard widgets.
Q: In a large app, how do you invalidate useAsyncData cache after a mutation?
A: Call refreshNuxtData('key') to trigger a refetch, or clearNuxtData('key') to remove the cached value entirely. Pair this with the refresh() method returned from useFetch for optimistic updates.
Q: How does useFetch handle reactive query params?
A: Pass a reactive object to the query option. useFetch watches it and refetches automatically when any value changes, which is one reason it beats a raw $fetch call inside a watchEffect.
Examples
Product list by dynamic category
<!-- pages/products/[category].vue -->
<script setup>
const route = useRoute()
// Cache key becomes '/api/products/electronics?limit=20&sort=price'
// Refetches automatically when route.params.category changes
const { data: products, pending } = await useFetch(
`/api/products/${route.params.category}`,
{ query: { limit: 20, sort: 'price' } }
)
</script>
<template>
<div v-if="pending">Loading...</div>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}</li>
</ul>
</template>useFetch is enough here because the URL already captures all variation. No manual key, no watcher needed.
Dashboard with parallel requests and partial failure handling
<script setup>
const { data: userId } = useAuth() // your auth composable
// Unique key per user, parallel fetches, handles partial failures
const { data: dashboard, error } = await useAsyncData(
`dashboard-${userId.value}`,
async () => {
const [stats, recent, notifications] = await Promise.allSettled([
$fetch('/api/stats'),
$fetch('/api/recent-activity'),
$fetch('/api/notifications')
])
// Stats are non-negotiable - throw if missing
if (stats.status === 'rejected') throw new Error('Stats unavailable')
return {
stats: stats.value,
recent: recent.status === 'fulfilled' ? recent.value : [],
notifications: notifications.status === 'fulfilled' ? notifications.value : []
}
},
{ default: () => null }
)
</script>useAsyncData handles this because three separate calls, a merge step, and per-user caching are not what useFetch was designed for. The key includes userId so each user gets their own cached payload.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.