Fullstack with Nitro

Server caching and performance

The server-side cache stack in Nuxt — defineCachedEventHandler and cachedFunction with their options, storage drivers for multi-instance deployments, route rules, CDN headers, invalidation without tags, request coalescing — and the render and query costs that caching cannot fix — sequential awaits, N+1, cold starts, payload size, streaming and observability.

"The API is slow" and "the page is slow to render" have the same first question, where does the time go, and the same first mistake, adding a cache before knowing. This page is the Nitro toolkit for caching correctly and the list of server costs that only code changes fix. The performance section covers measurement in depth; the HTTP page covers the headers.

Know

  • Layers, outermost first: CDN (by URL and Vary; s-maxage, swr/isr route rules) → Nitro response cache (defineCachedEventHandler, routeRules.cache) → function cache (cachedFunction) → database and upstream caches (Redis, query cache) → the query itself. Cache as far out as correctness allows; personalised data never in a shared layer.
  • defineCachedEventHandler(handler, options) caches the whole response: maxAge (seconds), swr (serve stale while revalidating, default true), staleMaxAge, getKey(event) (default: the URL; include what changes the result, never the whole cookie), varies (headers that join the key), name/group (storage prefixes for targeted clearing), shouldBypassCache, shouldInvalidateCache, base (storage mount), headersOnly. routeRules.cache does the same declaratively ({ maxAge, swr, varies }) and swr: N/isr: N are shortcuts.
  • cachedFunction(fn, options) (alias defineCachedFunction) memoises any async function with the same options plus getKey(...args): right for expensive upstream calls or computations shared by several routes.
  • Storage: cache entries live in the cache storage mount, in memory by default, which does not survive restarts and is not shared between instances or serverless invocations; production sets nitro.storage.cache to Redis, Cloudflare KV, or the platform's store. Cache size and TTLs must be bounded (memory driver + unbounded keys = a leak).
  • Invalidation: Nitro's cache has no tags; use name/group prefixes and clear with useStorage('cache').clear('nitro:handlers:name') or removeItem(key) after mutations, keep TTLs short enough that stale data is acceptable, or use shouldInvalidateCache for explicit bypasses (an admin ?refresh=1 with auth). CDN caches are purged through the provider API or by changing URLs.
  • Coalescing: the cache shares in-flight promises, so a hundred concurrent requests for a cold key produce one upstream call; the same idea in the app layer is dedupe: 'defer' on useAsyncData.
  • Correctness rules: never cache responses that depend on the user without putting the user in the key (and then ask whether caching is worth it); never cache Set-Cookie; include locale, currency and device class in the key when they change the output; return Vary to the CDN for the same headers.
  • Render cost, which caching hides: sequential awaits in setup (fix with Promise.all, lazy: true for non-critical data), N+1 queries (data layer), oversized payloads (pick/transform, DTOs), heavy synchronous work in handlers (move to tasks or workers), large dependency graphs (nuxt analyze for the server bundle; defineLazyEventHandler), and cold starts on serverless and edge (smaller server bundles, fewer top-level imports, lazy handlers, provisioned concurrency where available).
  • Streaming: experimental.ssrStreaming (4.5, off by default) flushes the shell early; handlers can return ReadableStreams so large exports never buffer.
  • Protecting the server: timeouts on outbound calls (AbortSignal.timeout), retries with backoff only for idempotent calls, circuit breakers for flaky upstreams, rate limits, bounded pagination and body sizes (security).
  • Observability: Server-Timing headers per phase, structured logs with cache hit/miss, request ids, p50/p95/p99 latencies per route, and a load test (autocannon, k6) against the built server before and after each change.

How it works

Response caching with a correct key and targeted invalidation:

server/api/catalog/products.get.ts
export default defineCachedEventHandler(async (event) => {
  const { category = 'all', locale = 'en' } = getQuery(event) as Record<string, string>
  const products = await listPublishedProducts(useDb(), { category })
  return products.map(p => toProductDto(p, locale))
}, {
  name: 'catalog',                       // storage prefix: nitro:handlers:catalog:*
  maxAge: 60 * 5,
  swr: true,                             // serve stale, revalidate in the background
  staleMaxAge: 60 * 60,
  getKey: (event) => {
    const { category = 'all', locale = 'en' } = getQuery(event) as Record<string, string>
    return `${locale}:${category}`       // everything that changes the output, nothing that identifies the user
  },
  shouldBypassCache: event => getHeader(event, 'x-preview') === useRuntimeConfig(event).previewToken,
})
server/api/admin/products/[id].patch.ts
export default defineEventHandler(async (event) => {
  requireRole(event, 'admin')
  const product = await updateProduct(useDb(), getRouterParam(event, 'id')!, await readValidatedBody(event, updateProductSchema.parse))
  await useStorage('cache').clear('nitro:handlers:catalog') // targeted invalidation by prefix; the CDN follows on its own swr
  return toProductDto(product)
})

A cached upstream call shared by routes, with a Redis-backed store in production:

server/utils/rates.ts
export const getExchangeRates = cachedFunction(async (base: string) => {
  return $fetch<Record<string, number>>('https://rates.example.com/latest', { query: { base }, signal: AbortSignal.timeout(2_000) })
}, {
  name: 'rates',
  maxAge: 60 * 15,
  swr: true,
  getKey: base => base.toUpperCase(),
})
nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/**': { swr: 300 },                            // rendered HTML, cached by Nitro, served stale-while-revalidate
    '/api/catalog/**': { cache: { maxAge: 300, swr: true, varies: ['accept-language'] } },
    '/api/account/**': { headers: { 'cache-control': 'private, no-store' } },
  },
  nitro: {
    storage: { cache: { driver: 'redis', url: process.env.REDIS_URL } }, // shared across instances; memory in dev
    experimental: { openAPI: true },
  },
})

Finding the real cost before caching: timings per phase and a load test.

server/plugins/timing.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', (event) => { event.context.t0 = performance.now() })
  nitroApp.hooks.hook('beforeResponse', (event) => {
    const total = performance.now() - event.context.t0
    setResponseHeader(event, 'server-timing', `total;dur=${total.toFixed(1)}${event.context.dbMs ? `, db;dur=${event.context.dbMs.toFixed(1)}` : ''}`)
    if (total > 500) console.warn(JSON.stringify({ level: 'warn', msg: 'slow request', path: event.path, total, requestId: event.context.requestId }))
  })
})
pnpm nuxt build && node .output/server/index.mjs &
npx autocannon -c 50 -d 20 http://localhost:3000/api/catalog/products     # p99 before
# add the cached handler, rebuild, repeat; compare p99 and the database query count
Gotcha· Caching a personalised response

defineCachedEventHandler keyed by URL on a route that reads event.context.user serves the first user's data to everyone for maxAge. Either the key includes the user (and the cache is per user, which rarely pays off) or the personalised part moves out of the cached response.

Gotcha· Memory cache on two instances

With the default memory driver, an invalidation on instance A leaves instance B serving stale data until its TTL, and a serverless function's cache disappears with the instance. Shared storage (Redis, KV) is the production configuration, and TTLs must assume invalidation can miss.

Verify before the interview:

The current defineCachedEventHandler option names (swr, staleMaxAge, varies, shouldBypassCache) and the storage key prefix used for handlers, plus whether experimental.ssrStreaming is still experimental in the latest 4.x.

docs ↗

Exercise

Exercise
  • Load-test a database-backed route with autocannon before and after defineCachedEventHandler; record p99 and the number of queries.
  • Deliberately cache a personalised route, request it as two users, and observe the leak; fix the key or the design.
  • Switch the cache storage to Redis (Docker), run two server instances behind a small proxy, and confirm an invalidation on one is seen by the other.

Be able to say

Be able to say· How do you add caching to a slow Nuxt API without breaking correctness?

"First I measure where the time goes with Server-Timing and a load test, because a slow query or a sequential chain of awaits is fixed in code, not hidden by a cache. When the response is shared and the staleness is acceptable, I wrap the handler in defineCachedEventHandler with a key that contains exactly what changes the output, locale, category, page, and nothing that identifies the user, a short maxAge with swr so users never wait on revalidation, and a name prefix so a mutation can clear the entries for that resource. Storage is Redis or the platform KV in production because the default memory cache is per instance. At the edge I add swr route rules or s-maxage headers so the CDN takes most of the traffic, and personalised routes are marked private, no-store and stay out of every shared layer."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.