Server time is where most "the app is slow" reports actually live, and it has only a handful of causes: you waited for data serially, you fetched more than you render, you serialised it all into the payload, or you missed the cache. Each has a lever, and one of them has a rule attached that interviewers listen for.
await useAsyncData(...) calls in setup add up. Start them without awaiting and await Promise.all([...]), or mark non-critical ones lazy: true so navigation is not blocked.pick and transform shrink what enters the payload; deep: false (the default in Nuxt 4) avoids deep reactivity on large objects. Transform runs once on the server, so the client pays nothing.useState value and every async-data result is serialised into every SSR response. Measure it in DevTools → Payload, keep runtimeConfig.public tiny, and move large derived or static data into a build-time template instead.event.$fetch / $fetch to your own routes goes through Nitro's local dispatcher with no network hop; from a component during SSR, useRequestFetch() forwards cookies and headers.routeRules (prerender at build, isr, swr with a maxAge, cache), handler-level defineCachedEventHandler with getKey, varies, maxAge, staleMaxAge and shouldBypassCache, and defineCachedFunction for expensive computations. The store behind all of them is unstorage: memory by default, so multi-instance deployments need a shared driver (Redis, KV) to share a cache.parallel: true, and make browser-only ones .client.node --cpu-prof .output/server/index.mjs under autocannon, then open the .cpuprofile in Chrome DevTools; add Server-Timing from a Nitro plugin for per-request visibility.Fixing a waterfall without changing what the page renders:
<script setup lang="ts">
// ✗ three round trips, one after another
// const { data: user } = await useAsyncData('user', () => $fetch('/api/user'))
// const { data: orders } = await useAsyncData('orders', () => $fetch('/api/orders'))
// const { data: news } = await useAsyncData('news', () => $fetch('/api/news'))
// ✓ start them together, await once; the slowest one sets the TTFB
const userReq = useAsyncData('user', () => $fetch('/api/user'))
const ordersReq = useAsyncData('orders', () => $fetch('/api/orders'))
const [{ data: user }, { data: orders }] = await Promise.all([userReq, ordersReq])
// ✓ non-critical: do not block navigation at all
const { data: news } = await useAsyncData('news', () => $fetch('/api/news'), { lazy: true })
</script>
Shrinking the payload at the source:
<script setup lang="ts">
const { data: products } = await useAsyncData(
'products',
() => $fetch('/api/products'),
{
// Runs on the server; only the result crosses the wire.
transform: list => list.map(p => ({ id: p.id, name: p.name, price: p.price })),
},
)
</script>
Caching with a bounded, deliberate key:
export default defineCachedEventHandler(
async (event) => {
const { region } = getQuery(event)
return await buildExpensiveReport(String(region ?? 'eu'))
},
{
maxAge: 60,
staleMaxAge: 300,
// Only these inputs may enter the key: arbitrary query params would make it unbounded.
getKey: event => `report:${String(getQuery(event).region ?? 'eu')}`,
// Anything personalised must vary, or it must not be cached at all.
varies: ['accept-language'],
shouldBypassCache: event => !!getCookie(event, 'preview'),
},
)
Route rules for the whole-page cases:
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // built once at build time
'/blog/**': { isr: 3600 }, // rendered on demand, then cached for an hour
'/pricing': { swr: 600 }, // served stale while revalidating
'/account/**': { ssr: true, cache: false }, // personalised: never cached
'/admin/**': { ssr: false }, // pure SPA, no server render at all
},
})
defineCachedEventHandler caches per key. If the response depends on the session cookie and the key does not, the first user's data is served to the next. Either include the identity in the key (and accept the memory cost), vary on the cookie, or do not cache. Route-level swr/isr on a page that renders a signed-in user has the same failure mode.
The default store is in-process, so with three pods you get three caches, a third of the hit rate, and inconsistent output. Mount a shared unstorage driver before quoting a hit ratio.
useAsyncData calls against an endpoint that sleeps 300 ms. Measure TTFB with curl -w, parallelise, measure again.swr: 30 to a route and curl -I it twice; read the cache headers and the x-nitro-* headers.getKey includes a random query param, hammer it with autocannon and watch memory grow. Bound the key and repeat."First at the data: sequential awaits in setup are the most common cause, so I parallelise them or mark non-critical ones lazy, and I check whether a cache that used to hit stopped hitting. Then at what enters the payload, because everything in useState and async data is serialised into every response, and pick or transform on the server costs nothing on the client. Then at plugins, which run before render on every request. I profile with --cpu-prof under load and expose Server-Timing so the split between data, render and serialisation is visible. Caching is the last lever, and never on a personalised route unless the key or varies includes the identity."
Build and dev performance
What module setup, pre-bundling and template generation cost your teammates, how to measure build time properly, and the levers that actually move it.
Client-side performance
Code splitting, lazy hydration, images and fonts, third-party scripts, and the Core Web Vitals each of them moves.