Nuxt useFetch vs $fetch: The Double-Fetch Trap in Every New App
Last month I was reviewing a client's Nuxt app before launch, network tab open, watching requests scroll past. A page loaded, and there it was: /api/posts firing twice. Same endpoint, same params, once from the server render and once more the moment the browser hydrated. The app worked fine. Nobody noticed anything. But every page load was quietly paying for the same request twice.
If you've written any Nuxt code, you've probably done what I did on my first project: reached for $fetch everywhere because it looks like the normal way to fetch things. It took me an embarrassing number of pages before someone pointed out the actual difference between $fetch, useFetch, and useAsyncData, and why the choice matters for every page that renders on the server.
The one fact that explains everything
$fetch is just ofetch with a global alias. It knows nothing about SSR. When you call it in a component's setup block on a server-rendered page, it runs once on the server to produce HTML, and then runs again in the browser during hydration, because nothing carries the server's result over. The client has no idea the data already exists.
useFetch and useAsyncData fix exactly this. They serialize the server-side result into the Nuxt payload, and during hydration the client reads it from there instead of re-requesting. One request, total.
// Anti-pattern: fires on the server AND again on the client
const posts = await $fetch('/api/posts')
// Fetches once on the server, hydrates from the payload
const { data: posts } = await useFetch('/api/posts')
That's the whole mental model. Everything else is picking which tool fits which call.
So when is $fetch the right call?
Whenever the request comes from a user action in the browser. Form submits, button clicks, search-as-you-type. No SSR involved, no payload to worry about:
async function submit() {
await $fetch('/api/contact', {
method: 'POST',
body: formData.value,
})
await refresh() // re-run the useFetch above if you list data
}
There's a second place $fetch shines that surprised me: calling your own server routes from server code. When a server route calls $fetch('/api/user'), Nuxt skips the network entirely and calls the handler function in the same process. No real HTTP round trip. Calling external APIs still goes over the wire, of course.
useFetch or useAsyncData?
They return the same object and share the same options. useFetch(url) is basically useAsyncData(key, () => $fetch(url)). So the rule is simple: one URL, use useFetch. Anything more complicated, reach for useAsyncData, which wraps any async function:
const { data, error } = await useAsyncData('dashboard', async () => {
const [user, stats, notifications] = await Promise.all([
$fetch('/api/user'),
$fetch('/api/stats'),
$fetch('/api/notifications'),
])
return { user, stats, notifications }
})
useFetch can't do this because it only handles a single endpoint. Also worth knowing: if your page data comes from a database or another non-HTTP source, useAsyncData is the only option of the two.
Four gotchas that actually bit me
The return shape gives you data, status ('idle' | 'pending' | 'success' | 'error'), error, and refresh. Prefer checking status over the pending ref. Beyond that, a few options have teeth:
- Reactive params. Pass a ref or computed as the url, query, or body and Nuxt refetches when it changes. But a plain interpolated string like
useFetch(`/api/posts/${id.value}`)is captured once and will not react. Use the options object for dynamic values. - await and lazy are independent. Without
await, code after the call runs immediately and client-side navigation doesn't block. That sounds likelazy: true, but they're not identical: if youawaita lazy call, the await resolves instantly on client-side navigation anyway. If you want navigation to wait for data, droplazy. If you want it non-blocking, uselazyexplicitly instead of just skipping the await. - data is a shallowRef by default. Mutating a nested property won't trigger updates. Either replace the whole value or pass
deep: true. - dedupe defaults to 'cancel'. Two concurrent calls with the same key means the first gets aborted. Fine most of the time, confusing if you fire the same key from two components and expect both to finish.
One more that costs nothing and saves debugging time: transform runs before the payload is serialized, and pick keeps only the keys you list. Both shrink the payload that ships to the browser. If your API returns fat objects, this is free bandwidth.
The five-second check
Open Nuxt DevTools and look at the Payload tab. If your page's data is in there, hydration reused it and you did it right. If it's missing and you see the request fire again in the network tab, something in that component is using $fetch where it should use useFetch.
This pairs nicely with the server routes I wrote about earlier (when I replaced an Express API with Nuxt server routes): once your backend lives in the same app, internal $fetch calls become direct function calls, and the only requests worth spending are the ones the payload can't cover.
I keep a small cheat sheet of these composables in Snippet Ark, because this is exactly the kind of thing I re-derive from scratch every six months and then feel silly about. Maybe you do too.