cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Caching under the hood

Read Next.js 16's caching source: how 'use cache' wraps a function and builds its key, where entries are stored, and how tags expire them.

By the end, you can
  • 01
    Follow a cached callTrace a 'use cache' function from the compiler's wrapper to its key, the resume data cache, the handler and the stored entry.
  • 02
    Say where an entry livesTell the built-in handler, the incremental cache, the build's memory and a route's resume data cache apart.
  • 03
    Predict what a tag doesRead the handler calls behind updateTag, revalidateTag and revalidatePath, and the read that comes after each.

From 'use cache' to a stored entry

'use cache', cache lifetimes and tags and revalidating data showed what caching does from your code. Underneath, Next.js 16.4.3 runs a cached function through a wrapper, in server/use-cache/use-cache-wrapper.ts, which builds a key, looks for a stored result, and stores a new one through a cache handler: an object with get and set methods that keeps the entries. An entry is one stored result: the function's encoded output, plus its lifetimes and its tags. A tag is a name given to entries so they can be expired together.

The app is the tea shop's menu. getMenu is cached for an hour and tagged menu; /menu shows it in the currency of the address, and /today greets the visitor by a cookie. To watch the cache from outside, the project's next.config.ts sets cacheHandlers.default to a file that wraps Next.js's own built-in handler and prints every call. It reaches into an internal path of next, so it's a study tool for this version, never something for an app. This is a real Next.js 16.4.3 project, built with next build and run with next start, and the recorder asked for /today, then /menu?currency=usd twice, then /menu?currency=eur. Open the Terminal tab:

The menu, with a handler that prints every callNext.js
app/menu/today/cache-handlers/lib/
lib/menu.ts
import { cacheLife, cacheTag } from "next/cache"; export async function getMenu(currency: string) {  "use cache";  cacheLife("hours");  cacheTag("menu");  console.log(`getMenu ran for ${currency}`);  const prices = currency === "eur" ? ["€3", "€4"] : ["$3", "$4"];  return [    { name: "Mint tea", price: prices[0] },    { name: "Green tea", price: prices[1] },  ];}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled- Experiments (use with caution):  ⨯ turbopackMinify   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...refreshTagsget ["c06e3edb815bb6354db1ce78f96faf8ca2bba9280b",["usd"],["<buildId>"]] missinggetMenu ran for usdset ["c06e3edb815bb6354db1ce78f96faf8ca2bba9280b",["usd"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)      Revalidate  Expire┌ ○ /_not-found├ ◐ /menu└ ◐ /today               1h      1d ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local:         http://127.0.0.1:3000- Network:       http://127.0.0.1:3000✓ ReadyrefreshTagsget ["c06e3edb815bb6354db1ce78f96faf8ca2bba9280b",["usd"],["<buildId>"]] missinggetMenu ran for usdset ["c06e3edb815bb6354db1ce78f96faf8ca2bba9280b",["usd"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}refreshTagsget ["c06e3edb815bb6354db1ce78f96faf8ca2bba9280b",["usd"],["<buildId>"]] found, revalidate 3600getExpiration ["_N_T_/layout","_N_T_/menu/layout","_N_T_/menu/page","_N_T_/menu"] never revalidatedrefreshTagsget ["c06e3edb815bb6354db1ce78f96faf8ca2bba9280b",["eur"],["<buildId>"]] missinggetMenu ran for eurset ["c06e3edb815bb6354db1ce78f96faf8ca2bba9280b",["eur"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}

In lib/menu.ts, line 4 makes getMenu a cached function, line 5 gives it the hours lifetime and line 6 the tag menu. Line 7 prints a line each time the function really runs. Here is what the handler printed under next start (each key's long function id cut to its first characters):

What the printing handler logged under next start, recordedText
refreshTags
get ["c06e3edb…",["usd"],["<buildId>"]] missing
getMenu ran for usd
set ["c06e3edb…",["usd"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}
refreshTags
get ["c06e3edb…",["usd"],["<buildId>"]] found, revalidate 3600
getExpiration ["_N_T_/layout","_N_T_/menu/layout","_N_T_/menu/page","_N_T_/menu"] never revalidated
refreshTags
get ["c06e3edb…",["eur"],["<buildId>"]] missing
getMenu ran for eur
set ["c06e3edb…",["eur"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}
  1. /today came first, and printed nothing at all. Lines 1 to 4 are the first visit to /menu in dollars: get found nothing, getMenu ran, and set stored the result.
  2. Lines 5 to 7, the second visit: get found it, and getMenu didn't run.
  3. Lines 8 to 11, euros: a different key, so another miss, run and store.
getMenu with usd goes into the cache wrapper, which makes a key. The key is looked up in the resume data cache, then in the cache handler. A hit returns the stored result. A miss runs getMenu, encodes its result, sends one copy back to the caller and stores the other with set.getMenu("usd")cache()keyresume data cachethen the handler: gethandlerget · setmissrun, encode, teeresult to the callerthe other copy goes to set
One call to getMenu("usd"). The wrapper builds the key, then asks the route's resume data cache and the handler. Only a miss runs your function; its result is encoded once and goes two ways: back to the caller, and into the handler.
Real-life analogyA tea shop's order shelf

A tea shop writes a ticket for every drink it makes and puts the drink on a shelf. Before making one, the staff check the bag kept for that customer, then the shelf, by ticket. A new day's batch has new tickets. When a recipe changes, a sticker goes on every drink with that recipe's name.

In real life: The ticket on each bag: the drink, the size, the day's batch
In Next.js: The key: the function's id, its arguments, the build's id
In real life: The bag under the counter, put aside for one customer's next visit
In Next.js: A route's resume data cache
In real life: The shelf of ready drinks behind the counter
In Next.js: The cache handler's store
In real life: A sticker saying the recipe changed
In Next.js: A tag marked stale or expired
The main idea

A cached function becomes a call to cache(), which encodes the function's id, arguments and build into a key, reads the route's resume data cache, then the handler, and only on a miss runs the function and stores the encoded result. Lifetimes and tags travel with the entry; revalidation marks tags in the handler.

The 'use cache' wrapper

The compiler, SWC (SWC and Turbopack showed it), rewrites every "use cache" function. The recorder kept the module the build made from lib/menu.ts (with Turbopack's minifier off, line 10 of next.config.ts). With the bundler's module names replaced by the names the transform gives those imports (server_actions.rs, lines 2586 and 2587), it reads:

lib/menu.ts as the build compiled it, recorded (module names readable)JavaScript
const $$RSC_SERVER_CACHE_0_INNER = async function getMenu(currency) {
    cacheLife("hours");
    cacheTag("menu");
    console.log(`getMenu ran for ${currency}`);
    // … the rest of getMenu, as written
};
var $$RSC_SERVER_CACHE_0 = $$reactCache__(function getMenu() {
    return $$cache__("default", "c06e3edb815bb6354db1ce78f96faf8ca2bba9280b", 0, $$RSC_SERVER_CACHE_0_INNER, Array.prototype.slice.call(arguments, 0, 1));
});
registerServerReference($$RSC_SERVER_CACHE_0, "c06e3edb815bb6354db1ce78f96faf8ca2bba9280b", null);
Object["defineProperty"]($$RSC_SERVER_CACHE_0, "name", {
    value: "getMenu"
});
var getMenu = $$RSC_SERVER_CACHE_0;
  1. Lines 1 to 6: your function, renamed $$RSC_SERVER_CACHE_0_INNER, its body unchanged.
  2. Lines 7 and 8: the new getMenu. React's cache ($$reactCache__) shares one call's result within a render; inside it, $$cache__ gets the kind "default", the function's id, 0 bound arguments, your function, and the arguments of this call.
  3. Line 10 registers it as a server reference with the same id, the way a Server Action is registered ('use client' and 'use server' under the hood).
  4. Line 14: the name you export is the wrapper.

$$cache__ is cache from use-cache-wrapper.ts, lines 1867 to 1873:

The wrapper's entry: use-cache-wrapper.ts, lines 1867 to 1873TypeScript
export async function cache(
  kind: string,
  id: string,
  boundArgsLength: number,
  originalFn: (...args: unknown[]) => Promise<unknown>,
  args: unknown[]
) {

Line 2, kind, picks the handler: "default" for "use cache", "remote" for "use cache: remote" (lines 1943 to 1946: getCacheHandler(kind), or an error for an unknown one). Line 4 counts the values a closure captured, which come first, encrypted (Server Actions under the hood, later in this module, reads how). On a miss, the function runs like this, from lines 1360 to 1750:

Running the function and encoding its result, shortened from use-cache-wrapper.tsTypeScript
const [, args] =
  typeof encodedArguments === 'string'
    ? await decodeReply<CacheKeyParts>(
        encodedArguments,
        getServerModuleMap(),
        { temporaryReferences }
      )
// …
const resultPromise = createLazyResult(fn.bind(null, ...args))
// …
    stream = renderToReadableStream(
      resultPromise,
      clientReferenceManifest.clientModules,
      {
        environmentName: 'Cache',
        filterStackFrame,
        temporaryReferences,
        onError: handleError,
      }
    )
// …
const [returnStream, savedStream] = stream.tee()

Lines 1 to 7 decode the arguments back out of the encoded key, with decodeReply. Line 9 calls your function with them: it sees exactly what the key holds, never the original objects. Lines 11 to 20 render the result with React's Flight server, renderToReadableStream, the same format as the RSC payload. Line 22 copies the stream: one copy goes back to the caller, the other becomes the entry the handler's set receives. That's why a cached function may return only what Flight can encode ('use cache' listed it).

What goes into a cache key

The key comes from three parts, in use-cache-wrapper.ts, lines 2289 to 2297:

The key's parts, encoded: use-cache-wrapper.ts, lines 2289 to 2297TypeScript
const { implementationPart, runtimeEnvVarStateHash } =
  await computeCacheKeyImplementationPart(workStore, workUnitStore, id)
const cacheKeyParts: CacheKeyParts = [id, args, implementationPart]

const encodeCacheKeyParts = () =>
  encodeReply(cacheKeyParts, {
    temporaryReferences,
    signal: hangingInputAbortSignal,
  })

Line 3: the function's id, the arguments, and an implementation part. Lines 5 to 9 encode the list with encodeReply, React's encoder for values sent to the server, the one Server Actions use for their arguments; for plain values like these, the result is a string of JSON. The implementation part, from lines 3917 to 3931:

The key's last part, shortened from use-cache-wrapper.tsTypeScript
const buildId = workStore.deploymentId || workStore.buildId
// …
const hmrRefreshHash = getHmrRefreshHash(workUnitStore)
// …
return {
  implementationPart: hmrRefreshHash
    ? [buildId, hmrRefreshHash]
    : [buildId],
  runtimeEnvVarStateHash: undefined,

Line 1: the deployment's id if you set one (deploymentId in next.config.ts), otherwise the build's. Lines 5 to 8: in next dev, a hash that changes when you edit a Server Component joins it. So a new build starts with new keys: the source's comment above line 1 says the build's id is there because ids don't yet change with the code. (With experimental.durableUseCacheEntries, the part is a hash of the code and the Next.js version instead, lines 3872 to 3911.) The recording shows the key as the handler got it, with the build's id replaced by <buildId>: line 2 of the log is ["c0…",["usd"],["<buildId>"]], and euros, line 9, differ only in the middle.

Three cards side by side: the function's id, which names getMenu in lib/menu.ts; the arguments, a list with usd; and the build's id. An arrow from all three goes down to one long card, the encoded key."c0…"which function["usd"]its arguments["<buildId>"]which buildencodeReply["c0…",["usd"],["<buildId>"]]
cacheKeyParts is the function's id, its arguments, and a list with the build's id (or the deployment's). encodeReply turns the three into one string, the key the recording printed.

The first part is the id from line 8 of the compiled code. The build's server reference manifest lists it, with the file and the pages that use it (recorded, the encryption key hidden):

.next/server/server-reference-manifest.js, recordedJavaScript
self.__RSC_SERVER_MANIFEST="{\n  \"node\": {\n    \"c06e3edb815bb6354db1ce78f96faf8ca2bba9280b\": {\n      \"workers\": {\n        \"app/menu/page\": {\n          \"moduleId\": 95511,\n          \"async\": false\n        },\n        \"app/today/page\": {\n          \"moduleId\": 19306,\n          \"async\": false\n        }\n      },\n      \"filename\": \"lib/menu.ts\",\n      \"exportedName\": \"$$RSC_SERVER_CACHE_0\"\n    }\n  },\n  \"edge\": {},\n  \"encryptionKey\": \"<encryptionKey>\"\n}"

The id names lib/menu.ts's $$RSC_SERVER_CACHE_0, used by app/menu/page and app/today/page. A private cache ("use cache: private") adds nothing about cookies to this key in production: its entries never reach a handler (Private and remote caches).

cacheLife defaults in config-shared

The set line of the log shows the lifetime the entry was stored with: {"stale":300,"revalidate":3600,"expire":86400}, in seconds. Those numbers are the hours profile, from the default config, in config-shared.ts, lines 2346 to 2379:

The built-in cacheLife profiles: config-shared.ts, lines 2346 to 2379TypeScript
cacheLife: {
  default: {
    stale: undefined, // defaults to staleTimes.static
    revalidate: 60 * 15, // 15 minutes
    expire: INFINITE_CACHE,
  },
  seconds: {
    stale: 30, // 30 seconds
    revalidate: 1, // 1 second
    expire: 60, // 1 minute
  },
  minutes: {
    stale: 60 * 5, // 5 minutes
    revalidate: 60, // 1 minute
    expire: 60 * 60, // 1 hour
  },
  hours: {
    stale: 60 * 5, // 5 minutes
    revalidate: 60 * 60, // 1 hour
    expire: 60 * 60 * 24, // 1 day
  },
  days: {
    stale: 60 * 5, // 5 minutes
    revalidate: 60 * 60 * 24, // 1 day
    expire: 60 * 60 * 24 * 7, // 1 week
  },
  weeks: {
    stale: 60 * 5, // 5 minutes
    revalidate: 60 * 60 * 24 * 7, // 1 week
    expire: 60 * 60 * 24 * 30, // 1 month
  },
  max: {
    stale: 60 * 5, // 5 minutes
    revalidate: 60 * 60 * 24 * 30, // 1 month

Lines 17 to 21 are hours: 5 minutes, an hour, a day. Line 3: default has no stale of its own and uses staleTimes.static, 300 seconds unless you change it. Line 5: INFINITE_CACHE, never expires. The whole table, to read:

The built-in profiles, in seconds
Profilestalerevalidateexpire
defaultstaleTimes.static: 300 (5 minutes)900 (15 minutes)Never (INFINITE_CACHE)
seconds30160 (1 minute)
minutes300 (5 minutes)60 (1 minute)3,600 (1 hour)
hours300 (5 minutes)3,600 (1 hour)86,400 (1 day)
days300 (5 minutes)86,400 (1 day)604,800 (1 week)
weeks300 (5 minutes)604,800 (1 week)2,592,000 (30 days)
max300 (5 minutes)2,592,000 (30 days)31,536,000 (365 days)

Your cacheLife option in next.config.ts is merged over this table. The function cacheLife() looks a name up in it and keeps the shortest time, shortened from cache-life.ts, lines 56 to 103:

cacheLife keeps the shortest time, shortened from cache-life.tsTypeScript
if (typeof profile === 'string') {
  const workStore = workAsyncStorage.getStore()
// …
  const configuredProfile = workStore.cacheLifeProfiles[profile]
// …
  profile = configuredProfile
// …
if (profile.revalidate !== undefined) {
  // Track the explicit revalidate time.
  if (
    workUnitStore.explicitRevalidate === undefined ||
    workUnitStore.explicitRevalidate > profile.revalidate
  ) {
    workUnitStore.explicitRevalidate = profile.revalidate
  }
}

Line 4 reads the profile by name from workStore.cacheLifeProfiles (your profiles merged over the table, in config.ts, lines 1490 to 1500). Lines 8 to 16 store its revalidate on the cache's own store unless a shorter one is there already; expire and stale follow on the next lines of the file. So two cacheLife calls in one function keep the shortest. The build printed the route's result too: ◐ /today with 1h and 1d in the Revalidate and Expire columns of the Terminal tab.

Cache handlers and the incremental cache

Every handler, built-in or yours, has the same five methods, from lib/cache-handlers/types.ts, lines 68 to 117 (the left-out lines are comments):

The CacheHandler interface, shortened from types.tsTypeScript
export interface CacheHandler {
// …
  get(cacheKey: string, softTags: string[]): Promise<undefined | CacheEntry>
// …
  set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>
// …
  refreshTags(): Promise<void>
// …
  getExpiration(tags: string[]): Promise<Timestamp>
// …
  updateTags(tags: string[], durations?: { expire?: number }): Promise<void>
}
  • Line 3, get: an entry for a key, or nothing. It also gets the request's soft tags, the tags a route gets from its path (they're read below).
  • Line 5, set: store an entry that may still be streaming in. The comments say Next.js never deletes entries itself; the handler must evict them.
  • Line 7, refreshTags: called once per request before its first read, as the log printed.
  • Line 9, getExpiration: when a set of tags was last revalidated.
  • Line 11, updateTags: mark tags revalidated, with an optional expire.

Without cacheHandlers, both default and remote are one built-in handler, from use-cache/handlers.ts, lines 106 to 113:

The built-in handler, for default and remote: handlers.ts, lines 106 to 113TypeScript
} else {
  const handler = createDefaultCacheHandler(builtInSize)
  builtInHandlers.add(handler)

  debug?.('setting "default" cache handler from default')
  handlersMap.set('default', handler)
  debug?.('setting "remote" cache handler from default')
  handlersMap.set('remote', handler)

Line 2 makes it with builtInSize, the cacheMaxMemorySize option: 50 MB by default. It's an LRU, a store that drops the least recently used entries when it's full, in this server's memory. Its get, from cache-handlers/default.ts, lines 114 to 137:

The built-in handler's get: default.ts, lines 114 to 137TypeScript
const maxAgeSeconds = process.env.__NEXT_DEV_SERVER
  ? Math.max(entry.expire, MIN_PRERENDERABLE_EXPIRE)
  : entry.revalidate

if (
  performance.timeOrigin + performance.now() >
  entry.timestamp + maxAgeSeconds * 1000
) {
  debug?.('get', cacheKey, 'expired')

  return undefined
}

let revalidate = entry.revalidate

if (areTagsExpired(entry.tags, entry.timestamp)) {
  debug?.('get', cacheKey, 'had expired tag')
  return undefined
}

if (areTagsStale(entry.tags, entry.timestamp)) {
  debug?.('get', cacheKey, 'had stale tag')
  revalidate = -1
}
  1. Lines 1 to 3: in production, an entry may live revalidate seconds, not expire. Lines 5 to 12: older ones are a miss. So with the built-in handler of this version, an hour-old getMenu result is filled again at the next call, in the foreground. A handler of your own may keep it until expire, and then Next.js serves it and refreshes it in the background.
  2. Lines 16 to 19: an entry with an expired tag is a miss too.
  3. Lines 21 to 24: one with a stale tag is returned with revalidate = -1, which means "serve it, but replace it".

The incremental cache

Prerendered pages, Route Handler answers and the pages ISR regenerates are kept by a different store, the incremental cache, configured with cacheHandler (singular). Without one, it's the file-system cache, from incremental-cache/index.ts, lines 222 to 238:

The incremental cache picks FileSystemCache, shortened from index.tsTypeScript
if (!CurCacheHandler) {
// …
  } else {
    if (fs && serverDistDir) {
      if (IncrementalCache.debug) {
        console.log('IncrementalCache: using filesystem cache handler')
      }
      CurCacheHandler = FileSystemCache
    }
  }

Line 8: FileSystemCache, which keeps a memory LRU and files. Where the files go, from file-system-cache.ts, lines 742 to 760:

Where the file-system cache writes each kind, file-system-cache.tsTypeScript
private getFilePath(key: string, kind: IncrementalCacheKind): string {
  let rootDir: string
  switch (kind) {
    case IncrementalCacheKind.FETCH:
      // we store in .next/cache/fetch-cache so it can be persisted
      // across deploys
      rootDir = path.join(this.serverDistDir, '..', 'cache', 'fetch-cache')
      break
    case IncrementalCacheKind.PAGES:
      rootDir = path.join(this.serverDistDir, 'pages')
      break
    case IncrementalCacheKind.IMAGE:
    case IncrementalCacheKind.APP_PAGE:
    case IncrementalCacheKind.APP_ROUTE:
      rootDir = path.join(this.serverDistDir, 'app')
      break
    default:
      throw new Error(`Unexpected file path kind: ${kind}`)
  }

Lines 13 to 15: pages and Route Handler answers go under .next/server/app, beside the files the build wrote. (Pages regenerated at runtime get a key under route-cache, a folder beside it in this version; Incremental Static Regeneration recorded one.) Lines 4 to 8: the older fetch cache goes to .next/cache/fetch-cache. Each row is one kind of cached thing, and where this version keeps it:

Where cached entries live in Next.js 16.4.3
What is cachedWhere it's keptHow long, by default
A 'use cache' result filled for a requestThe built-in handler: this server's memory, a 50 MB LRU shared with 'use cache: remote' unless you set a handler.In production its get drops an entry once revalidate has passed, so the next call misses and fills it again.
The same result filled during next buildThe build process's memory.Gone when the build ends; next start begins with an empty handler.
The results a ◐ route's prerender filledThat route's resume data cache, saved in its .meta file with the postponed state.Read first when the same route is resumed for a request, before any handler.
Prerendered pages and Route Handler answers (and ISR's regenerated ones)The incremental cache: FileSystemCache (a memory LRU, and files under .next/server), or your cacheHandler.Regenerated after revalidate in the background, or replaced by a tag.
A 'use cache: remote' result, with cacheHandlers.remote setYour handler's store, like Redis.As long as your store keeps it; Next.js never deletes entries itself.
Internals, not an API

The compiled names, the key's layout, the handler files, _N_T_ tags and the resume data format are Next.js 16.4.3's internals, from this recorded build. What's documented is the cacheHandlers interface, the profiles' meaning, and what revalidation does. Write a handler against the documented interface, never against Next.js's internal files.

The resume data cache

Why did /today print nothing? Its Greeting reads the cookie, so the route is ◐: the build prerenders a static shell and saves what's needed to finish it for a request, the postponed state (Prerendering and Partial Prerendering, earlier in this stage, reads it). But the build ran Greeting up to the cookie, and getMenu("usd") comes before it. The build printed what it did:

What the printing handler logged during next build, recordedText
get ["c06e3edb…",["usd"],["<buildId>"]] missing
getMenu ran for usd
set ["c06e3edb…",["usd"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}

Lines 1 to 3: the build missed, ran getMenu and stored the entry in its own memory, gone when the build ends. It also kept the entry with the route, in its resume data cache: the cached results the prerender filled. When the route is resumed for a request, that cache is read first, from use-cache-wrapper.ts, lines 2461 to 2839:

The resume data cache comes first, shortened from use-cache-wrapper.tsTypeScript
const resumeDataCache = getResumeDataCache(workUnitStore)
// …
if (resumeDataCache) {
// …
  let rdcEntry = resumeDataCache.cache.get(serializedCacheKey)
// …
    if (rdcResult !== undefined) {
      debug?.(logPrefix, 'Resume Data Cache entry found', serializedCacheKey)

Line 5 looks the key up in it, and line 8 is the hit: no handler is asked, so the request printed no refreshTags and no get. /menu had no such luck: its prerender stopped at searchParams, before getMenu, so its first visit missed in the handler, though the build had filled the same key for /today. The resume data cache is saved with the postponed state, compressed, at the end of the route's .meta file; Prerendering and Partial Prerendering took that value apart. The recorder kept .next/server/app/today.meta. Decompressed, its one cached entry is:

The resume data cache in today.meta, decompressedText
key: ["c06e3edb…",["usd"],["<buildId>"]]
tags: ["menu"]
stale: 300, revalidate: 3600, expire: 86400
value:
0:"$@1"
1:[{"name":"Mint tea","price":"$$3"},{"name":"Green tea","price":"$$4"}]

Line 1 is the key on line 1 of the build log. Lines 5 and 6 are the stored result: rows in the Flight format, like the RSC payload, with $$3 for the text $3 (The RSC payload reads $ values). The route's own cache tags are in the same file's headers: _N_T_/layout,_N_T_/today/layout,_N_T_/today/page,_N_T_/today,menu, so revalidating menu also reaches /today's saved shell.

Left, next build: its memory holds the usd entry, and the today.meta file it writes carries the entry in its resume data. Right, next start: the built-in handler in the server's memory starts empty, and the incremental cache reads the prerendered files under .next/server. An arrow carries today.meta from the build to the server; the build's memory is crossed out.next buildmemory: usd entrytoday.metaresume data insideprerendered filesnext starthandler: emptyresume /todayincremental cache
The build's memory ends with the build. What reaches next start is what the build wrote to disk: the prerendered files, read by the incremental cache, and each ◐ route's resume data, read when that route is resumed.

Explicit and soft tags

A tag is a name that marks entries, so one call can expire all of them. There are two kinds. An explicit tag is one you give with cacheTag, from cache-tag.ts, lines 35 to 41:

cacheTag adds to the entry's tags: cache-tag.ts, lines 35 to 41TypeScript
const validTags = validateTags(tags, '`cacheTag()`')

if (!workUnitStore.tags) {
  workUnitStore.tags = validTags
} else {
  workUnitStore.tags.push(...validTags)
}

Lines 3 to 7 add the tags to the cache's store, and they end up in the entry: "tags":["menu"] in the set line. A soft tag (the source also calls them implicit tags) comes from the route that's being rendered, from lib/implicit-tags.ts, lines 26 to 57:

The soft tags of a route, shortened from implicit-tags.tsTypeScript
const getDerivedTags = (pathname: string): string[] => {
  const derivedTags: string[] = [`/layout`]
// …
  if (pathname.startsWith('/')) {
    let end = pathname.indexOf('/', 1)

// …
      let curPathname = pathname.slice(0, end)
      if (curPathname) {
        // all derived tags other than the page are layout tags
        if (!curPathname.endsWith('/page') && !curPathname.endsWith('/route')) {
          curPathname = `${curPathname}${
            !curPathname.endsWith('/') ? '/' : ''
          }layout`
        }
        derivedTags.push(curPathname)
// …
  return derivedTags
}

Line 2 starts with /layout, the root layout. Lines 8 to 16 add one tag per folder of the page's path, each ending in /layout, and the page itself. With the prefix _N_T_ and the request's own path added, a visit to /menu brings four: line 7 of the start log, ["_N_T_/layout","_N_T_/menu/layout","_N_T_/menu/page","_N_T_/menu"]. They're never stored in the entry; a read checks them, with getExpiration (printed only once an entry was found, since it's fetched when first needed). Here is the read that checks both kinds, from use-cache-wrapper.ts, lines 3269 to 3328:

Then the cache handler, shortened from use-cache-wrapper.tsTypeScript
entry = await cacheHandler.get(cacheHandlerKey, implicitTags)
// …
if (
  shouldDiscardCacheEntry(
    entry,
    workStore,
    workUnitStore,
    implicitTags,
    implicitTagsExpiration,
    logPrefix
  )
) {
  debug?.(logPrefix, 'discarding expired entry', cacheHandlerKey)
  entry = undefined
}

Line 1 asks the handler, passing the soft tags. Lines 3 to 15: a found entry is thrown away when shouldDiscardCacheEntry says one of its tags, or the soft tags' time from getExpiration (line 9), is later than the entry.

How revalidation reaches the handler

The three functions, shortened from web/spec-extension/revalidate.ts, lines 37 to 136:

revalidateTag, updateTag and revalidatePath, shortened from revalidate.tsTypeScript
export function revalidateTag(tag: string, profile: string | CacheLifeConfig) {
// …
  return revalidate(
    [encodeHeaderSafe(tag)],
    `revalidateTag(${JSON.stringify(tag)})`,
    profile
  )
}
// …
export function updateTag(tag: string) {
// …
  return revalidate(
    [encodeHeaderSafe(tag)],
    `updateTag(${JSON.stringify(tag)})`,
    undefined
  )
}
// …
export function revalidatePath(originalPath: string, type?: 'layout' | 'page') {
// …
  let normalizedPath = `${NEXT_CACHE_IMPLICIT_TAG_ID}${encodeHeaderSafe(removeTrailingSlash(originalPath))}`
// …
  const tags = [normalizedPath]
// …
  return revalidate(tags, `revalidatePath(${JSON.stringify(originalPath)})`)
}

Lines 3 to 7: revalidateTag passes its profile on. Lines 12 to 16: updateTag passes undefined, no profile. Lines 21 to 25: revalidatePath("/menu") turns the path into the soft tag _N_T_/menu, with no profile. All three call revalidate, which only writes them down, from lines 216 to 263:

Revalidations wait in the work store, shortened from revalidate.tsTypeScript
if (!store.pendingRevalidatedTags) {
  store.pendingRevalidatedTags = []
}
// …
    store.pendingRevalidatedTags.push({
      tag,
      profile,
      revalidatedAt,
    })
// …
if (!profile || cacheLife?.expire === 0) {
  // TODO: only revalidate if the path matches
  store.pathWasRevalidated = ActionDidRevalidate
}

Lines 1 to 9 add each tag to pendingRevalidatedTags, with its profile and the time. Lines 11 to 13: without a profile (or with expire: 0), the action's page is rendered again, so it shows your own change. When the work is done, each handler gets the tags, grouped by profile, from revalidation-utils.ts, lines 169 to 184:

Each handler's updateTags, shortened from revalidation-utils.tsTypeScript
  if (cacheLife) {
    durations = {
      expire: cacheLife.expire,
    }
  }
// …
for (const handler of handlers) {
  if (profile) {
    promises.push(handler.updateTags?.(tagsForProfile, durations))
  } else {
    promises.push(handler.updateTags?.(tagsForProfile))
  }
}

Lines 1 to 5: a profile becomes durations, its expire only. Lines 8 to 12: with a profile, the handler gets the durations; without one, nothing more. The built-in handler then marks the tag, from default.ts, lines 218 to 241:

The built-in handler's updateTags, shortened from default.tsTypeScript
async updateTags(tags, durations) {
  const now = Math.round(performance.timeOrigin + performance.now())
// …
  for (const tag of tags) {
    // TODO: update file-system-cache?
    const existingEntry = tagsManifest.get(tag) || {}
// …
    if (durations) {
// …
      updates.stale = now

      if (durations.expire !== undefined) {
        updates.expired = now + durations.expire * 1000 // Convert seconds to ms
      }

      tagsManifest.set(tag, updates)
    } else {
      // Update expired field for immediate expiration (default behavior when no durations provided)
      tagsManifest.set(tag, { ...existingEntry, expired: now })
    }

Lines 8 to 16: with durations, the tag is stale now and expires expire seconds later. Line 19: without them, it's expired now. In the playground, the staff page's form calls one of the three in a Server Action. It's a stand-in: a real staff page checks who is asking first (Securing Server Actions). Pick each, and read the Terminal's last lines:

Playground: three ways to expire the menu
The tea shop, with this version of app/staff/page.tsxNext.js
app/menu/staff/today/cache-handlers/lib/
app/staff/page.tsx
import { updateTag } from "next/cache"; // A stand-in for a staff-only page: a real one checks who is asking first.export default function StaffPage() {  async function pricesChanged() {    "use server";    updateTag("menu");  }  return (    <form action={pricesChanged}>      <button>Prices changed</button>    </form>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled- Experiments (use with caution):  ⨯ turbopackMinify   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...refreshTagsget ["c070fa189ab1f4d1387ade5d8625d11d75c7195d99",["usd"],["<buildId>"]] missinggetMenu ran for usdset ["c070fa189ab1f4d1387ade5d8625d11d75c7195d99",["usd"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}✓ Generating static pages (5/5)  Finalizing page optimization ... Route (app)      Revalidate  Expire┌ ○ /_not-found├ ◐ /menu├ ○ /staff└ ◐ /today               1h      1d ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local:         http://127.0.0.1:3000- Network:       http://127.0.0.1:3000✓ ReadyrefreshTagsget ["c070fa189ab1f4d1387ade5d8625d11d75c7195d99",["usd"],["<buildId>"]] missinggetMenu ran for usdset ["c070fa189ab1f4d1387ade5d8625d11d75c7195d99",["usd"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}refreshTagsget ["c070fa189ab1f4d1387ade5d8625d11d75c7195d99",["usd"],["<buildId>"]] found, revalidate 3600getExpiration ["_N_T_/layout","_N_T_/menu/layout","_N_T_/menu/page","_N_T_/menu"] never revalidatedupdateTags ["menu"] "no durations"updateTags ["menu"] "no durations"refreshTagsget ["c070fa189ab1f4d1387ade5d8625d11d75c7195d99",["usd"],["<buildId>"]] missinggetMenu ran for usdset ["c070fa189ab1f4d1387ade5d8625d11d75c7195d99",["usd"],["<buildId>"]] {"tags":["menu"],"stale":300,"revalidate":3600,"expire":86400}
Try it yourself
updateTag("menu")

Line 7 calls updateTag("menu"). After the form, the handler printed updateTags ["menu"] "no durations", twice. The next visit's get was missing, so getMenu ran for usd again before the page was sent.

Every version was built with next build from Next.js 16.4.3 and run with next start. The Terminal shows what the printing handler logged: two visits to /menu?currency=usd, the staff form, then one more visit. Reset goes back to the first version.

updateTag and revalidatePath reached the handler twice: they ask for the action's page to render again, so the action handler applies pending revalidations before that render (action-handler.ts, lines 1509 to 1512), and app-render applies them again when it ends (app-render.tsx, lines 3177 and 3178). revalidateTag with a profile doesn't ask for that render, so its tags were applied once, at the end. After revalidateTag("menu", "max"), the visitor got the old menu: that's stale-while-revalidate, where the visit that finds a stale entry is served it and starts a refresh, and a later visit is fresh once that refresh has finished. The wrapper does it after a handler hit, in use-cache-wrapper.ts, lines 3696 to 3808:

A stale entry starts a background regeneration, shortened from use-cache-wrapper.tsTypeScript
stream = sharedCacheEntry.fork()
resolvableSharedCacheResult.resolve({
  type: 'cached',
  entry: sharedCacheEntry,
})
// …
let shouldTriggerBackgroundRevalidation =
  currentTime > entry.timestamp + entry.revalidate * 1000
// …
if (
  shouldTriggerBackgroundRevalidation &&
// …
  !backgroundRevalidations.has(cacheHandlerKey)
) {
// …
  const revalidatePromise = Promise.resolve()
    .then(() =>
      generateCacheEntry(
// …
  backgroundRevalidations.set(
    revalidateCacheHandlerKey,
    revalidatePromise
  )

Lines 1 to 5: the stored entry goes to this render, stale or not. Lines 7 and 8: it's stale once its revalidate seconds have passed, which a revalidate of -1 always has. Lines 10 to 18: unless a regeneration for this key is already running, generateCacheEntry runs the function again, and lines 20 to 23 keep that work in backgroundRevalidations, under the handler's key. (The recording doesn't print that second run: when it happens depends on timing.)

Three rows. updateTag menu: updateTags with no durations, then get returns nothing and getMenu runs. revalidateTag menu max: updateTags with expire, then get returns the old entry with revalidate -1, served while a new one is made. revalidatePath /menu: updateTags with the soft tag _N_T_/menu, then get finds the entry, getExpiration says revalidated, and the entry is thrown away.updateTagget: missing, runrevalidateTag maxget: old, revalidate -1revalidatePathget: found, discardedexpired now: the page waits for a new onestale: served, then refreshed in the backgroundthe soft tag _N_T_/menu was revalidated
Each call reaches the handler as updateTags. What the next read gets depends on whether the tag was expired now, marked stale, or was a soft tag that only the request's getExpiration sees.

Each row is one call, what the handler gets, and what the next read of the menu gets:

What each call does to the menu's entry
The callWhat the handler getsWhat the next read gets
updateTag("menu")updateTags(["menu"]), no durations: the tag is expired now.get returns nothing, so getMenu runs before the page is sent.
revalidateTag("menu", "max")updateTags(["menu"], { expire: 31536000 }): the tag is stale now.get returns the old entry with revalidate: -1: it's served, and the wrapper starts generateCacheEntry in the background for a fresh one.
revalidatePath("/menu")updateTags(["_N_T_/menu"]): a soft tag, expired now.get finds the entry, getExpiration reports the soft tag as revalidated, and the entry is thrown away.

Watching the caches in your own app

You can watch the same calls in your own app, in a production build. Use it to learn and to debug, never from your app's code:

  • Write a handler of your own against the documented cacheHandlers interface that keeps entries in a Map and prints each call (Private and remote caches has one), and set it as cacheHandlers.default.
  • Look at the build's route table: the Revalidate and Expire columns show the shortest lifetime each route's cached work has.
  • Look in .next/server/app: a ◐ route's .meta file lists its cache tags in x-next-cache-tags.
  • In this version, the environment variable NEXT_PRIVATE_DEBUG_CACHE=1 makes the wrapper and the built-in handlers print what they do. It's private: it may change or go away in any release.

Sort each value: is it part of the key, stored in the entry, or a soft tag the request brings?

Key, entry, or soft tag?
  • The function's id, c0…
  • The argument "usd"
  • The build's id
  • revalidate: 3600, from cacheLife("hours")
  • The tag menu, from cacheTag
  • _N_T_/menu/page
Try it yourself
0 of 6 correct

Sort each value: part of the cache key, stored in the entry, or a soft tag the request brings.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The key is the function's name and its arguments." No. It's the compiler's id, the arguments as encoded, and the build's (or deployment's) id.
  • "Entries the build filled are there when next start begins." No. The build's memory ends with it; only a ◐ route's resume data cache carries entries its own prerender filled, for that route.
  • "The built-in handler keeps an entry until expire." No. In production in this version, its get drops an entry once revalidate has passed.
  • "revalidateTag with a profile deletes the entry." No. It marks the tag stale: the next visit is served the old entry and starts a refresh. updateTag expires it now.
  • "revalidatePath finds entries by their URL." No. It expires a soft tag, _N_T_/menu, which each request to that path checks with getExpiration.
  • "A cached function receives the objects you passed." No. It receives the arguments decoded from the encoded key.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints. Some ask you to find a line in Next.js's source, and the excerpts above hold it.

Exercise 1 · Warm-upRead two keys

These are the get lines of the recorded log. Which part of the key differs between dollars and euros?

Starter codeText
get ["c06e3edb…",["usd"],["<buildId>"]] missing
getMenu ran for usd
get ["c06e3edb…",["usd"],["<buildId>"]] found, revalidate 3600
getExpiration ["_N_T_/layout","_N_T_/menu/layout","_N_T_/menu/page","_N_T_/menu"] never revalidated
get ["c06e3edb…",["eur"],["<buildId>"]] missing
getMenu ran for eur

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the profiles

    How many seconds is revalidate for cacheLife("days")?

    Starter codeTypeScript
    cacheLife: {
      default: {
        stale: undefined, // defaults to staleTimes.static
        revalidate: 60 * 15, // 15 minutes
        expire: INFINITE_CACHE,
      },
      seconds: {
        stale: 30, // 30 seconds
        revalidate: 1, // 1 second
        expire: 60, // 1 minute
      },
      minutes: {
        stale: 60 * 5, // 5 minutes
        revalidate: 60, // 1 minute
        expire: 60 * 60, // 1 hour
      },
      hours: {
        stale: 60 * 5, // 5 minutes
        revalidate: 60 * 60, // 1 hour
        expire: 60 * 60 * 24, // 1 day
      },
      days: {
        stale: 60 * 5, // 5 minutes
        revalidate: 60 * 60 * 24, // 1 day
        expire: 60 * 60 * 24 * 7, // 1 week
      },
      weeks: {
        stale: 60 * 5, // 5 minutes
        revalidate: 60 * 60 * 24 * 7, // 1 week
        expire: 60 * 60 * 24 * 30, // 1 month
      },
      max: {
        stale: 60 * 5, // 5 minutes
        revalidate: 60 * 60 * 24 * 30, // 1 month

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind it in the source: a deployment's id

      When you set a deployment id, what does Next.js put in the key's last part instead of the build's id?

      Starter codeTypeScript
      const buildId = workStore.deploymentId || workStore.buildId
      // …
      const hmrRefreshHash = getHmrRefreshHash(workUnitStore)
      // …
      return {
        implementationPart: hmrRefreshHash
          ? [buildId, hmrRefreshHash]
          : [buildId],
        runtimeEnvVarStateHash: undefined,

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict the handler calls

        Under next start, how many calls does the cache handler get for the first visit to /today?

        Starter codeTSX
        import { Suspense } from "react";
        import { cookies } from "next/headers";
        import { getMenu } from "@/lib/menu";
        
        export default function TodayPage() {
          return (
            <main>
              <h1>Today</h1>
              <Suspense fallback={<p>Loading your tea…</p>}>
                <Greeting />
              </Suspense>
            </main>
          );
        }
        
        async function Greeting() {
          const menu = await getMenu("usd");
          const name = (await cookies()).get("name")?.value ?? "guest";
          return (
            <p>
              Hello, {name}. {menu[0].name} is {menu[0].price} today.
            </p>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePredict the read after revalidateTag

          After revalidateTag("menu", "max"), what does this get return for the menu's entry?

          Starter codeTypeScript
          const maxAgeSeconds = process.env.__NEXT_DEV_SERVER
            ? Math.max(entry.expire, MIN_PRERENDERABLE_EXPIRE)
            : entry.revalidate
          
          if (
            performance.timeOrigin + performance.now() >
            entry.timestamp + maxAgeSeconds * 1000
          ) {
            debug?.('get', cacheKey, 'expired')
          
            return undefined
          }
          
          let revalidate = entry.revalidate
          
          if (areTagsExpired(entry.tags, entry.timestamp)) {
            debug?.('get', cacheKey, 'had expired tag')
            return undefined
          }
          
          if (areTagsStale(entry.tags, entry.timestamp)) {
            debug?.('get', cacheKey, 'had stale tag')
            revalidate = -1
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFind it in the source: soft tags

            Which handler method tells Next.js when a request's soft tags were last revalidated?

            Starter codeTypeScript
            export interface CacheHandler {
            // …
              get(cacheKey: string, softTags: string[]): Promise<undefined | CacheEntry>
            // …
              set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>
            // …
              refreshTags(): Promise<void>
            // …
              getExpiration(tags: string[]): Promise<Timestamp>
            // …
              updateTags(tags: string[], durations?: { expire?: number }): Promise<void>
            }

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the value lives: in the key, in the entry, in a tag, or in a route's resume data.

              The caching internals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the compiler turn a 'use cache' function into, in Next.js 16.4.3?

                Choose an answer to see the explanation.

              2. Question 2 of 7Which three parts make a 'use cache' key in a production build?

                Choose an answer to see the explanation.

              3. Question 3 of 7Where do the built-in cacheLife profiles' numbers come from?

                Choose an answer to see the explanation.

              4. Question 4 of 7In production, what does the built-in handler's get do with an entry past its revalidate time?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why did /today, a ◐ route, make no cache handler calls at all under next start?

                Choose an answer to see the explanation.

              6. Question 6 of 7After revalidateTag("menu", "max"), what does the next read of the menu get from the built-in handler?

                Choose an answer to see the explanation.

              7. Question 7 of 7How does revalidatePath("/menu") reach an entry whose only tag is menu?

                Choose an answer to see the explanation.

              Key takeaways

              • The compiler turns a "use cache" function into a call to cache(kind, id, boundArgs, fn, args), inside React's cache.
              • The key is [id, args, [buildId]], encoded with encodeReply; the function runs with the arguments decoded from it, and its result is stored in the Flight format.
              • The built-in profiles are a table in config-shared.ts; cacheLife keeps the shortest time, and the entry carries it.
              • The built-in handler is a 50 MB LRU in each server's memory, shared by default and remote; in production it drops entries after revalidate. Pages go to the incremental cache.
              • A ◐ route's resume data cache carries entries its own prerender filled, and is read before any handler.
              • Explicit tags live in entries; soft tags come from the route. updateTag expires now, revalidateTag with a profile marks stale, and revalidatePath expires a soft tag.

              Remember the one-liner.
              A cached call is a key (function, arguments, build) looked up in the route's resume data, then the handler, and filled only on a miss.

              Coming next: Server Actions under the hood. It reads how an action's id is made, the Next-Action request, and how one response carries the result and the new UI.

              CompleteFrontend Clear concepts. Working examples.