Every Nuxt team has shipped the same bug: data fetched with plain $fetch in setup, running once on the server and again in the browser, doubling API load and occasionally flashing mismatched content. The fix is understanding that Nuxt's three fetching tools solve three different problems.
Which tool, when
useFetch(url): the default for loading data a page or component needs; it runs during SSR, serializes the result into the payload, and skips refetching on the client. Sugar foruseAsyncData+$fetch.useAsyncData(key, fn): the same lifecycle for anything beyond a single GET: multiple calls combined, SDK methods, conditional logic. The key is what deduplicates it.$fetch: for event handlers and server routes, submitting a form, calling an API after a click, where SSR deduplication is irrelevant because the user triggered it.
The habits that prevent the classic bugs
Give useAsyncData stable, unique keys, since colliding keys silently share responses between unrelated callers. Use the pick or transform options to strip fetched objects to the fields the page renders, because the whole payload ships to the browser inside the HTML. Guard against null in templates: data is null until resolved, and during client-side navigation your component renders before the fetch completes.
Refreshing and reacting
Every useAsyncData returns refresh(), and reactive keys or the watch option refetch automatically when a route param or filter changes; reach for those before writing manual watchers. For mutations, call $fetch in the handler, then refresh() the affected data, and you get consistency without inventing a caching layer. The mental model that holds it together: useFetch is for data the page is made of, $fetch is for things the user does.