skip to content

In Nuxt 4, server middleware sets `event.context.warehouse` from a cookie and a `defineCachedEventHandler` route reads it. Why does every shopper then see the first shopper's stock, and how do you fix it?

level: seniorimportance: should knowfreq 24%

answer

  1. what the key is made of
  2. context passes, headers don't
  3. the first answer wins
  4. cache the shared part only
  5. a separator that survives

basics

~20 s

The cached handler's key comes from the URL and varies headers only, while event.context passes through unkeyed, so the first shopper's warehouse is cached for everyone. Put the warehouse into the key, or cache just the upstream call per warehouse.

solid answer

~50 s

Nitro's `defineCachedEventHandler` keys an entry on the request URL plus the headers listed in `varies`. The middleware's `event.context` is handed to the cached handler, so the first miss fetched stock for that shopper's warehouse and stored it under a key that says nothing about warehouses; every later shopper hits it. Reading the cookie inside the cached handler fails differently: Nitro gives the handler a copy of the request carrying only the `varies` headers, so `getCookie` returns `undefined`. Fixes: add the warehouse to the key with `getKey`, joined with `_` because Nitro strips non-word characters from custom keys; or send it as a header named in `varies`; or, usually best, keep the route uncached and cache only the upstream call with `defineCachedFunction`, keyed by warehouse and SKU. Never cache a response that depends on who is asking unless that identity is in the key.

code

ts · 14 lines
ts
// server/utils/stock.ts
export const getStock = defineCachedFunction(
  async (warehouse: string, sku: string) => {
    const { inventoryApiBase } = useRuntimeConfig()
    return await $fetch(`${inventoryApiBase}/warehouses/${warehouse}/items/${sku}`)
  },
  { maxAge: 60, name: 'stock', getKey: (warehouse: string, sku: string) => `${warehouse}_${sku}` },
)

// server/api/inventory/[sku].get.ts  (not cached itself)
export default defineEventHandler(async (event) => {
  const warehouse = event.context.warehouse ?? 'main'
  return await getStock(warehouse, getRouterParam(event, 'sku') ?? '')
})

go deeper

for a junior

Recall that a cached handler gives the same stored response to every request with the same key, and that the key comes from the URL.

for a middle

Explain why event.context reaches the handler but not the key, why cookies read inside it are undefined, and how getKey and varies change that.

for a senior

Show how you audit every input a cached response depends on, move per-user logic out of the cached part, and prove the fix with two sessions in one cache window.

for a principal

Set a team rule for caching in the BFF: what may be cached per URL, what needs an explicit key, and who reviews new cached handlers for data leaks.

## The setup An inventory backend-for-frontend in Nuxt 4 shows stock for the shopper's nearest warehouse: - `server/middleware/02.warehouse.ts` reads a `warehouse` cookie and sets `event.context.warehouse`. - `server/api/inventory/[sku].get.ts` is wrapped in `defineCachedEventHandler` with `maxAge: 60` and fetches `/warehouses/<warehouse>/items/<sku>` from the upstream service. In testing, one person sees correct numbers. In production, every shopper sees the stock of whichever warehouse the first shopper after each refresh belonged to. ## Why it happens Two facts about Nitro's cached handlers combine: 1. **The key ignores `event.context`.** The automatic key is the request path plus a hash of the full URL, plus the values of any headers named in `varies`. The warehouse lives in neither, so `/api/inventory/A1024` is one entry for all shoppers. 2. **The handler still sees `event.context`.** Nitro runs your handler against a proxied request, but it assigns the incoming `event.context` to it, so the middleware's value is readable. The first miss fetches for that shopper's warehouse, and the result is stored under the shared key. If you move the cookie read into the cached handler, you get a different bug: Nitro drops all incoming request headers except those in `varies`, so `getCookie(event, 'warehouse')` and `getHeader(event, 'authorization')` return `undefined`, and every shopper gets your fallback warehouse. | Where the per-shopper value is read | What the cached handler sees | Result | |---|---|---| | server middleware, into `event.context` | the value, but the key ignores it | first shopper's data for all | | a cookie, inside the handler | `undefined` | fallback data for all | | a header named in `varies` | the value, and the key includes it | correct, one entry per value | ## Fixes, from narrow to structural - **Put the discriminator in the key.** Give the handler a `getKey` that returns the warehouse and the SKU joined by an underscore, such as `W1_A1024`. Nitro removes every non-word character from a custom key, so a colon-joined `W1:A1024` would become `W1A1024` and collide with the pair `W1A` and `1024`; an underscore is a word character and survives. - **Vary on a dedicated header.** If the page sends `x-warehouse`, `varies: ['x-warehouse']` both passes that header to the handler and splits the key by its value. - **Cache the shared part, not the response.** Keep the route as a plain `defineEventHandler`, so it can read cookies and the session normally, and cache only the upstream call with `defineCachedFunction`, keyed by warehouse and SKU. - **Bypass for special callers.** `shouldBypassCache: (event) => Boolean(event.context.user?.isStaff)` sends staff straight to the upstream without touching the entry. A tempting fix, `varies: ['cookie']`, splits the cache per session cookie: nearly every entry is unique, the hit rate collapses and the in-memory store grows with every visitor. ## The rule behind it A cached handler serves **one response to everyone whose request produces the same key**. Before caching, list every input the response depends on: URL, query, headers, cookies, session, locale, feature flags. Each one must either be in the key or be something you are happy to share across all callers. Authorisation belongs outside the cached part entirely: check it in middleware or in the uncached handler, then call cached code for the data that is the same for everyone who passes the check. ## Why the uncached route with a cached function is usually best Splitting the work keeps each concern where it belongs: - The **route** stays a plain `defineEventHandler`. It sees the real request, so cookies, `Authorization` and the session all work, and it can refuse a shopper before any data is fetched. - The **cached function** holds only data that is identical for everyone with the same arguments. Its key is its arguments, so the warehouse cannot be forgotten: it is a parameter. - **Eviction is targeted.** A stock correction for one warehouse and SKU removes one entry, instead of guessing which URL-shaped handler keys were affected. The cost is that the route itself is not cached, so each request still runs the handler, and a conditional request no longer gets the automatic 304. For an inventory read that is usually cheap next to the upstream call, which is the part worth caching. ## How to catch it before production 1. Test with two sessions that differ in the discriminator, in the same cache window. 2. Log the computed key while developing. 3. Review every cached handler for reads of `event.context`, cookies or auth headers. 4. Treat a new cached handler on an authenticated path as a security review item, not a performance tweak.

  • What should happen to the Cache-Control header on a per-warehouse inventory response?
    Nitro's cached handler writes a Cache-Control header from `maxAge` and `swr`, which browsers or shared caches may honour. A response that differs per shopper must not be stored by a shared cache under one URL, so either cache the upstream call with `defineCachedFunction` and let the route send its own headers, or check what the cached handler emits before exposing it.
  • How would you evict one warehouse's entry after a stock correction?
    With the `defineCachedFunction` above, the entry lives in the `cache` mount under `nitro:functions:stock:<warehouse>_<sku>.json`, so `await useStorage('cache').removeItem('nitro:functions:stock:W1_A1024.json')` evicts it. With several instances this only works if `cache` is mounted on shared storage; the default is in memory per instance.

saying these in an interview costs you the question

  • Nitro's cached handler automatically keys entries per cookie, so per-user data is safe.
  • Reading the session cookie inside the cached handler gives each user their own entry.
  • event.context is part of the cache key because middleware ran first.
  • Adding varies: ['cookie'] is a cheap way to make the cache per-user.
  • A custom getKey is used exactly as written, separators included.