skip to content

In Nuxt 4, an inventory proxy is wrapped in `defineCachedEventHandler(handler, { maxAge: 60 })`. What happens on a miss, on a hit, and after 60 seconds?

level: seniorimportance: should knowfreq 33%

answer

  1. a response stored in storage
  2. the key comes from the URL
  3. stale first, refresh behind
  4. errors are never stored
  5. the default lifetime is tiny

basics

~20 s

A miss runs the handler and stores its response in Nitro's cache storage; a hit replays it without calling upstream. After 60 seconds the default swr: true serves the stale response while a background refresh replaces it.

solid answer

~50 s

`defineCachedEventHandler` stores a Nitro handler's whole response, status, headers and body, in the `cache` storage mount, keyed by the request URL (including its query) and any `varies` headers. On a miss the handler runs, concurrent requests for that key on the same instance share the one call, and the response is stored unless its status is 400 or above or its body is undefined, so errors are never cached. A hit replays the stored response with an ETag and Last-Modified, answering a matching conditional request with a 304. Once the entry is older than `maxAge`, the default `swr: true` returns the stale response immediately and refreshes in the background; if that refresh throws, the stale entry keeps being served. With `swr: false` the request waits for a fresh response. Without `maxAge`, entries count as fresh for only one second.

code

ts · 12 lines
ts
// server/api/inventory/[sku].get.ts
export default defineCachedEventHandler(async (event) => {
  const sku = getRouterParam(event, 'sku')
  const { inventoryApiBase, inventoryApiToken } = useRuntimeConfig(event)
  return await $fetch(`${inventoryApiBase}/items/${sku}`, {
    headers: { authorization: `Bearer ${inventoryApiToken}` },
  })
}, {
  maxAge: 60, // seconds; the default is 1
  swr: true, // the default: serve stale, refresh in the background
  name: 'inventory',
})

go deeper

for a junior

Recall that defineCachedEventHandler stores a whole response for maxAge seconds and replays it, and that maxAge defaults to one second.

for a middle

Explain the miss, hit and expired paths, the URL-based key, why errors are not stored, and what swr true versus false changes for the request after expiry.

for a senior

Show how you pick maxAge and swr for stock data, keep reads and writes apart, and use stale-on-error behaviour to ride out an upstream outage without hiding it.

for a principal

Weigh how much staleness each inventory view can tolerate against upstream load, and when caching belongs in the BFF versus a shared cache in front of the service.

## What the wrapper stores In a Nuxt 4 app, Nitro's `defineCachedEventHandler` (alias `cachedEventHandler`) is auto-imported inside `server/`. It takes the same handler function as `defineEventHandler` plus an options object, and it caches the **whole response**: the status code, the response headers and the body. Entries go into Nitro's storage layer under the `cache` mount, in the `nitro/handlers` group. Without extra configuration that mount is **in memory** in a production build and on the filesystem in the dev server. The **key** is built automatically from: - the start of the request path, plus a hash of the full URL including its query string, so `/api/inventory/A1024` and `/api/inventory/A1024?fields=qty` are separate entries; - the values of any request headers listed in `varies`. The HTTP method is not part of the key, so cache only read handlers, for example a file named `[sku].get.ts`. ## The three paths through a cached handler 1. **Miss.** No entry exists. The request waits while your handler runs. If other requests for the same key arrive on the same instance meanwhile, they wait for that same call instead of starting their own. The response is then stored, unless its status is 400 or higher or the body is `undefined`: errors are never cached. If the handler throws on a miss, the client gets that error and nothing is stored. 2. **Hit.** An entry exists and is younger than `maxAge`. Nitro replays the stored status, headers and body without calling your handler. It also sets `ETag` (a hash of the body unless the handler set one) and `Last-Modified`, and answers a request whose `If-None-Match` or `If-Modified-Since` matches with a bodiless 304. 3. **Expired.** The entry is older than `maxAge`. With the default `swr: true`, the request still gets the stale response at once, and Nitro runs your handler in the background to replace it; on runtimes that provide `event.waitUntil` it uses that to keep the instance alive until the refresh finishes. If the refresh throws, the error is logged and the stale entry stays in place for the next request. With `swr: false`, the expired entry is discarded and the request waits for a fresh response, so an upstream failure reaches the client. ## The options that change this | Option | Default | Effect | |---|---|---| | `maxAge` | `1` (second) | how long an entry counts as fresh | | `swr` | `true` | serve stale while refreshing, instead of waiting | | `name` | `'_'` | part of the storage key | | `getKey` | URL-based | a custom key, stripped of non-word characters | | `varies` | none | request headers that reach the handler and split the key | | `shouldBypassCache` | none | skip the cache for this request, keeping the entry | | `shouldInvalidateCache` | none | treat the entry as expired for this request | | `base` | `cache` | which storage mount holds the entries | Two defaults deserve a sentence each. **`maxAge` defaults to one second**, so a cached handler without options barely caches at all. And Nitro stamps entries with an **integrity** hash of the handler code and options, so after a deploy that changes either, old entries count as expired. ## What the handler can and cannot see Nitro runs your handler against a copy of the request that carries only the headers listed in `varies`; cookies and `Authorization` are dropped. `event.context`, filled by server middleware, is passed through. A response that depends on who is asking must therefore have that identity in its key, or must not be cached as a whole response. ## Where it fits in a backend-for-frontend For an inventory BFF, caching the per-SKU read for 60 seconds absorbs bursts (a product page shared widely) and keeps serving the last known stock while the upstream service is down. The price is staleness, and with `swr: true` it is **not bounded by `maxAge`**: an entry is only refreshed when a request finds it expired, and that request still receives the old copy. On a busy SKU the data is a little over a minute old at worst; on a SKU nobody viewed for three hours, the first visitor sees three-hour-old stock. Stock that must be exact at checkout goes through an uncached handler, for example `reserve.post.ts`. - Use a small `maxAge` and `swr: true` for stock shown on product pages, and accept that quiet pages can show older data. - Use `swr: false` where an old answer is worse than a slow one. - Keep reservations and anything per-user out of the cache. - Configure the `cache` mount before you run more than one instance, since the in-memory default is private to each instance.

  • What Cache-Control header does the cached inventory response carry?
    Nitro writes one from the options you passed. With `{ maxAge: 60 }` and no explicit `swr`, it sends `max-age=60`, so browsers keep their own copy for a minute even after you evict the server entry. Passing `swr: true` explicitly makes it `s-maxage=60, stale-while-revalidate`, which addresses shared caches instead. Check the header in the response before relying on either.
  • How is defineCachedFunction different from defineCachedEventHandler?
    `defineCachedFunction` caches the return value of any async function, keyed by a hash of its arguments or your `getKey`, in the `nitro/functions` group. It suits caching one upstream call that several handlers share, while the handler around it stays uncached and can read cookies. On edge runtimes, pass the event as the first argument so background refreshes can use `waitUntil`.
  • Can you get the same caching without changing the handler code?
    Yes: a `cache` route rule, for example `routeRules: { '/api/inventory/**': { cache: { maxAge: 60 } } }` in `nuxt.config.ts`, makes Nitro wrap the matching handlers in the same cached handler. It keeps policy in config, but the same header and key behaviour applies.

A bakery's display case: the first customer of the day waits while the oven runs, later customers take a loaf straight from the case, and once the loaves pass their best-before time the next customer still gets one from the case while a fresh batch bakes behind the counter.

saying these in an interview costs you the question

  • A cached handler with no options caches responses for a sensible default of several minutes.
  • After maxAge expires, the next request always waits for a fresh upstream response.
  • Error responses are cached too, so a failing upstream is hammered less.
  • The cache key includes the HTTP method, so caching a handler that also serves POST is safe.
  • Cached handlers keep entries in the browser, so each visitor has a separate cache.