Why await useFetch Freezes Page Navigation in Nuxt: Removing a 2.4s Wait with useLazyFetch
Clicking from a list to a detail page left the screen unchanged for 2.4 seconds. The cause: await useFetch plus a slow AI call inside the response. How useLazyFetch, server: false, and a skeleton fixed it, with measured results.
Click, and nothing happens for 2.4 seconds
I was building a tourism info service with Nuxt. Clicking a spot in the list opens its detail page, but for 2.4 seconds after the click, the screen didn't change. With no loading indicator and the list still showing, it felt like the click hadn't registered.
The detail page code looked like this:
// pages/spots/[id].vue
const { data: spot } = await useFetch(() => `/api/spots/${id.value}`)
And the detail API fetched data from the Korea Tourism Organization API, then generated a summary with the Claude API, returning everything at once. That summary call took 1 to 2 seconds.
Two causes stacked up:
| Cause | Effect |
|---|---|
| The detail API waited for a slow AI summary before responding | The detail response itself was 1 to 2 seconds late |
| The page used await useFetch | Navigation stopped until that response arrived |
await useFetch blocks navigation
When you await useFetch, Nuxt doesn't move to the next page until the data arrives. Meanwhile, the user keeps looking at the previous page.
That's easier to feel than to read, so I built an API that takes 2 seconds and measured it: the time from clicking a link on a list page until the next page's heading appears.
// a test API that responds after 2 seconds
export default defineEventHandler(async () => {
await new Promise((r) => setTimeout(r, 2000))
return { ok: true }
})
| Page code | Until the next page shows | Until the data arrives |
|---|---|---|
| await useFetch(...) | about 2,400ms | about 2,400ms |
| useLazyFetch(...) | about 100ms | about 2,100ms |
The data takes about 2 seconds either way. What differs is when the screen changes. await useFetch stays on the previous page until the data arrives; useLazyFetch moves to the next page right away and waits for the data there. (Nuxt 3.21 dev server, Chromium, measured twice each.)
useLazyFetch is useFetch with lazy on
// these are the same
const { data } = useLazyFetch('/api/spots/1')
const { data } = useFetch('/api/spots/1', { lazy: true })
With lazy, Nuxt shows the page without waiting for data. In exchange, the page has to handle data being empty at first.
On a first visit, both wait. When someone types the URL or refreshes, the server fetches the data while building the HTML (server-side rendering). Then useLazyFetch also waits for the data before sending HTML. Requesting the same test pages with curl took about 2 seconds for both, and the HTML had the data filled in. What lazy removes is the wait when navigating within the app.
Fix 1: Move the slow AI summary to its own API
Before switching to useLazyFetch, there was something to do first: make the response itself fast. I removed the AI summary call from the detail API and added a separate API that returns only the summary. (The code below is trimmed to what matters here.)
// server/api/spots/[id].get.ts — detail: no more AI summary call
const detail = toSpotDetail(item, intro, images)
// No AI summary here. The Claude call takes 1-2s and would delay the whole detail response.
return detail
// server/api/spots/[id]/summary.get.ts — summary only
export default defineEventHandler(async (event) => {
const contentId = getRouterParam(event, 'id')
try {
const { name, overview, summary } = await getSpot(contentId) // shares the detail cache
if (!overview) return { summary: null }
const aiSummary = await summarizeWithAI(contentId, name, overview)
return { summary: aiSummary ?? summary ?? null }
} catch {
return { summary: null } // the page works without a summary, so don't throw
}
})
That alone brought the detail API response down to 0.1 to 0.4 seconds. The summary API reads the source data with the same cache key as the detail API, so it doesn't call the tourism API again.
If slow work is "extra information the page can live without," don't tie it to the main response. Fetch it separately.
Fix 2: useLazyFetch for the page, server: false for the summary
// pages/spots/[id].vue
// detail: show the page without waiting
const { data: spot, error, status } = useLazyFetch(() => `/api/spots/${id.value}`, {
key: () => `spot-${id.value}`,
})
const pending = computed(() => status.value === 'pending')
// summary: fetch only in the browser
const { data: aiSummary, status: summaryStatus } = useLazyFetch(
() => `/api/spots/${id.value}/summary`,
{ key: () => `spot-summary-${id.value}`, server: false },
)
| Option | Effect |
|---|---|
| useLazyFetch (= lazy: true) | Doesn't wait for data when navigating within the app |
| server: false | Skips it entirely during server rendering and fetches only in the browser |
The summary gets server: false because of the warning above. With lazy alone, a first visit still makes the server wait for the summary, delaying the first HTML by 1 to 2 seconds. With server: false, the server sends HTML right away without the summary, and the browser fills it in later.
But content fetched with server: false isn't in the server HTML. Don't use it for main content that search engines must read; it fits extra information like this AI summary.
Fix 3: What to show while waiting
With useLazyFetch, the page appears first and spot is briefly empty. A blank screen there won't do, so I drew a skeleton the same size as the real layout.
<template>
<div v-if="error">Couldn't load this spot.</div>
<!-- same frame as the real screen, so nothing shifts when data arrives -->
<div v-else-if="pending" class="skeleton" aria-hidden="true">
<div class="skeleton__title" />
<div class="skeleton__media" />
<div v-for="n in 5" :key="n" class="skeleton__row" />
</div>
<template v-else-if="spot">
<h1>{{ spot.name }}</h1>
...
</template>
</template>
The skeleton matches the real layout's size so that text and photos don't jump down when the data arrives.
Draw the summary once, after it's final
At first I wanted to show a simple rule-based summary until the AI summary arrived. But then the text you're reading suddenly changes to the AI summary 1 to 2 seconds later. So while the summary loads, only a loading indicator shows, and the result is drawn once it's final. The rule-based summary is used only if the AI summary fails.
const summaryPending = computed(() => summaryStatus.value === 'pending')
const summary = computed(() => {
if (summaryPending.value) return '' // loading: show the indicator
return aiSummary.value?.summary || spot.value?.summary || '' // rule-based on failure
})
What changes once await is gone
Without await, you can't read spot.value right away in setup, because it starts empty. The original code logged the response with console.log(spot.value...) in setup; after switching to useLazyFetch, that always logs undefined. Move it into a watch so it runs when the data arrives.
watch(spot, (value) => {
if (!value) return
console.log('[detail] received', value)
}, { immediate: true })
Results
| Item | Before | After |
|---|---|---|
| From clicking list → detail until the screen changes | 2.4s (previous screen stays) | Immediate (skeleton) |
| Detail API response | Waited for the AI summary (1-2s) | 0.1-0.4s |
| AI summary | Arrived with the detail | Fetched separately in the browser, drawn once on arrival |
Summary
| Situation | Use |
|---|---|
| The page is meaningless without the data, and the response is fast | await useFetch (the default) |
| The response may be slow, and you want to show the screen first | useLazyFetch + loading/skeleton |
| Slow extra information (AI summaries, recommendations, stats) | A separate API + server: false |
| Main content search engines must read | Don't use server: false |
await useFetch is the simplest code, so it's easy to default to. But know that the moment the response is slow, navigation freezes for exactly that long. First pull the slow part out of the response, then make the remaining wait visible to users with lazy and a skeleton.
Problems caused by a wrong key in useFetch/useAsyncData are covered in Fixing Nuxt useAsyncData caching with dynamic keys.