Performance

Server-side performance

Waterfalls, payload size, the caching layers Nitro gives you, and the rule that keeps caching from becoming a security incident.

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.

Know

  • Waterfalls. Sequential 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.
  • Over-fetching. 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.
  • Payload size. Every 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.
  • Internal fetch. From a server route, 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.
  • Caching, in increasing scope: 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.
  • Never cache a personalised response without varying on the right cookie or header. This is both a correctness bug and a data-disclosure bug, and it is the caching question a senior is expected to raise unprompted.
  • Plugins run before render on every SSR request. Keep them light, mark independent ones parallel: true, and make browser-only ones .client.
  • Profiling: 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.

How it works

Fixing a waterfall without changing what the page renders:

app/pages/dashboard.vue
<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:

app/pages/products.vue
<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:

server/api/report.get.ts
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:

nuxt.config.ts
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
  },
})
Gotcha· A cached handler on a personalised route

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.

Gotcha· Memory cache in a multi-instance deployment

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.

Exercise

Exercise
  • Build a fixture page with three sequential useAsyncData calls against an endpoint that sleeps 300 ms. Measure TTFB with curl -w, parallelise, measure again.
  • Add swr: 30 to a route and curl -I it twice; read the cache headers and the x-nitro-* headers.
  • Write a cached handler whose getKey includes a random query param, hammer it with autocannon and watch memory grow. Bound the key and repeat.

Be able to say

Be able to say· TTFB is slow. Where do you look?

"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."