skip to content

A Next.js function marked with the `'use cache'` directive takes arguments and also reads a variable from its enclosing scope. What forms the cache key, and what does that require of the arguments and of the returned value?

level: middleimportance: should knowfreq 40%

answer

  1. arguments plus what it closes over
  2. serialize in, serialize out
  3. unstable_cache made you list key parts
  4. invisible inputs collide on one entry
  5. cardinality decides your hit rate

basics

~20 s

Next.js keys a 'use cache' entry on everything that flows into the call: the serialized arguments plus the values the function closes over. Both must be serializable to be part of the key, and the returned value must be serializable because it is what gets stored and replayed.

solid answer

~50 s

The key is derived from the inputs, and "inputs" means more than the parameter list. Next serializes the arguments you pass and the values the function closes over from its enclosing scope, and both contribute to the key — so the same function called with `('shoes')` and `('hats')` gets two entries, and a closed-over `locale` splits them further. That has three consequences. Arguments and closed-over values must be serializable, so you cannot pass a database client or a callback and expect sensible keying. The return value must also be serializable, because the cache stores it and replays it later instead of running the body. And anything the function reads that is *not* an input — a mutable module-level variable, the clock, a request-scoped API — is invisible to the key, so two calls that should differ can collide on one entry. This is the main behavioural upgrade over `unstable_cache`, where you had to list the key parts by hand.

code

typescript · 8 lines
typescript
export function makeLoader(locale: string) {
  // `locale` is closed over, so it becomes part of the cache key
  return async function load(slug: string) {
    'use cache'
    const res = await fetch(`https://api.example.com/${locale}/articles/${slug}`)
    return res.json()
  }
}

go deeper

for a junior

Recall that different arguments produce different cache entries, and that whatever you return has to be plain, serializable data rather than a live object.

for a middle

Explain that closed-over values join the arguments in the key, and that anything read inside the body but not passed in is invisible to keying and causes collisions.

for a senior

Bring up cardinality as a design decision — which parameters you allow, how many entries they produce, and why request-shaped inputs destroy the hit rate.

for a principal

Be ready to set a house rule for cached functions: pure data in, pure data out, narrow parameters, no ambient state, and a review checklist for what may cross into a cache key.

## The key is the whole input surface A cache is only correct if its key captures everything that can change the answer. For a `'use cache'` function, Next builds that key from the call's inputs: the arguments passed in, serialized, plus the values the function captures from its enclosing scope. Two calls whose inputs serialize identically hit the same entry; any difference produces a different entry. ```ts export async function getFeed(userTier: string) { 'use cache' const res = await fetch(`https://api.example.com/feed?tier=${userTier}`) return res.json() } ``` Call this with `'free'` and with `'pro'` and you get two independent entries. Nothing about the fetch URL is inspected — it is the *arguments* that key the entry, not the requests the body happens to make. ## Closures count too This is the part candidates miss. If the cached function is defined inside another function and reads a variable from that outer scope, that captured value is part of the input surface and Next includes it: ```ts export function makeLoader(locale: string) { return async function load(slug: string) { 'use cache' return fetchArticle(slug, locale) // locale is closed over } } ``` Entries are separated by `slug` *and* `locale`. Contrast that with the predecessor API, `unstable_cache(fn, keyParts, options)`, where you supplied `keyParts` yourself: forgetting to list a closed-over value there produced a genuinely wrong cache — one locale's article served under another's key. Deriving the key from the actual closure removes an entire class of that bug. ## Serializability, in both directions Because the key is built by serializing inputs, and because the stored value is replayed rather than recomputed, serializability constrains both ends: - **Inputs** must be serializable to participate in the key. Plain objects, arrays, strings, numbers, dates — fine. A live database client, a class instance with methods, or a callback is not something that can be reduced to a stable key. - **The return value** must be serializable, because it is written into the cache and read back on a later request in a different process. Return plain data, not handles, streams, or closures. The practical rule: a cached function should look like a pure data function — data in, data out. ## What the key cannot see Anything the body reads that did not arrive as an input is invisible to the key. That includes: - **Mutable module state** — a `let` at module scope that something else updates. Two calls with the same arguments return the first call's answer forever. - **Time** — `Date.now()` inside the body is captured into the stored value, and every replay hands back the old timestamp. Lifetime belongs in `cacheLife()`, not in the body. - **Request-scoped data** — cookies, headers and search params. Reading them inside a cached function is not just a keying problem, it is a cross-user data-leak problem, which is why Next rejects it outright. When a value genuinely varies per call, the fix is always the same: make it an argument, so it becomes part of the key. ## Granularity is a design choice Because every distinct input combination is its own entry, key cardinality is something you choose. Passing a whole request object, or a timestamp, gives every call a unique key and a 0% hit rate — a cache that costs storage and returns nothing. Passing a narrow, low-cardinality parameter (`category`, `locale`, `tier`) gives a small set of hot entries. Before adding a parameter to a cached function, ask how many distinct values it will take in production. ## The mental model Think of the cached function as a lookup table Next fills in for you. The columns are the inputs it can see; the cell is the serialized result. Anything you want to vary the answer must be a column, and anything that cannot be written down as a column has no business being read inside the body.

  • How does this differ from `unstable_cache`, the predecessor API?
    `unstable_cache(fn, keyParts, options)` required you to pass the key parts by hand as an array. Anything the wrapped function closed over but you forgot to list was absent from the key, so entries collided and served the wrong data. `'use cache'` derives the key from the actual arguments and closure, which removes that footgun.
  • What happens if a cached Next.js function calls `Date.now()` inside its body?
    The timestamp is computed once, stored in the entry, and replayed unchanged on every hit — so the value silently ages. Time is not an input the key can see. Express staleness with `cacheLife()` instead, or pass a coarse time bucket in as an argument if you truly need time in the key.
  • Why would passing a whole request or user object into a cached function be a mistake?
    It explodes key cardinality: nearly every call produces a distinct key, so the hit rate collapses and you pay storage and serialization for nothing. Worse, user-scoped fields make each entry personal data. Pass the narrow, low-cardinality values the query actually depends on.

saying these in an interview costs you the question

  • Thinks only the arguments are keyed, never closures
  • Believes the fetch URL inside the body forms the key
  • Returns a database client or class instance from a cached function
  • Uses Date.now() inside the body to control freshness
  • Passes the whole user object in and wonders why nothing hits

context