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.
- 01Follow 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.
- 02Say where an entry livesTell the built-in handler, the incremental cache, the build's memory and a route's resume data cache apart.
- 03Predict 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:
lib/menu.tsimport { 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] }, ];}$ 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):
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}/todaycame first, and printed nothing at all. Lines 1 to 4 are the first visit to/menuin dollars:getfound nothing,getMenuran, andsetstored the result.- Lines 5 to 7, the second visit:
getfound it, andgetMenudidn't run. - Lines 8 to 11, euros: a different key, so another miss, run and store.
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.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
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:
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;- Lines 1 to 6: your function, renamed
$$RSC_SERVER_CACHE_0_INNER, its body unchanged. - Lines 7 and 8: the new
getMenu. React'scache($$reactCache__) shares one call's result within a render; inside it,$$cache__gets the kind"default", the function's id,0bound arguments, your function, and the arguments of this call. - 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).
- Line 14: the name you export is the wrapper.
$$cache__ is cache from use-cache-wrapper.ts, lines 1867 to 1873:
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:
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:
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:
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.
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):
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:
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 monthLines 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:
| Profile | stale | revalidate | expire |
|---|---|---|---|
default | staleTimes.static: 300 (5 minutes) | 900 (15 minutes) | Never (INFINITE_CACHE) |
seconds | 30 | 1 | 60 (1 minute) |
minutes | 300 (5 minutes) | 60 (1 minute) | 3,600 (1 hour) |
hours | 300 (5 minutes) | 3,600 (1 hour) | 86,400 (1 day) |
days | 300 (5 minutes) | 86,400 (1 day) | 604,800 (1 week) |
weeks | 300 (5 minutes) | 604,800 (1 week) | 2,592,000 (30 days) |
max | 300 (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:
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):
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 optionalexpire.
Without cacheHandlers, both default and remote are one built-in handler, from use-cache/handlers.ts, lines 106 to 113:
} 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:
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
}- Lines 1 to 3: in production, an entry may live
revalidateseconds, notexpire. Lines 5 to 12: older ones are a miss. So with the built-in handler of this version, an hour-oldgetMenuresult is filled again at the next call, in the foreground. A handler of your own may keep it untilexpire, and then Next.js serves it and refreshes it in the background. - Lines 16 to 19: an entry with an expired tag is a miss too.
- 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:
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:
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:
| What is cached | Where it's kept | How long, by default |
|---|---|---|
A 'use cache' result filled for a request | The 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 build | The build process's memory. | Gone when the build ends; next start begins with an empty handler. |
The results a ◐ route's prerender filled | That 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 set | Your handler's store, like Redis. | As long as your store keeps it; Next.js never deletes entries itself. |
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:
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:
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:
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.
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.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
cacheHandlersinterface that keeps entries in aMapand prints each call (Private and remote caches has one), and set it ascacheHandlers.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.metafile lists its cache tags inx-next-cache-tags. - In this version, the environment variable
NEXT_PRIVATE_DEBUG_CACHE=1makes 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?
- The function's id,
c0… - The argument
"usd" - The build's id
revalidate: 3600, fromcacheLife("hours")- The tag
menu, fromcacheTag _N_T_/menu/page
Sort each value: part of the cache key, stored in the entry, or a soft tag the request brings.
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
getdrops an entry oncerevalidatehas 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.
updateTagexpires it now. - "revalidatePath finds entries by their URL." No. It expires a soft tag,
_N_T_/menu, which each request to that path checks withgetExpiration. - "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.
These are the get lines of the recorded log. Which part of the key differs between dollars and euros?
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 eurThe arguments, the second part: ["usd"] and ["eur"]. The function's id and the build's id are the same, so each currency has a key of its own.
How many seconds is revalidate for cacheLife("days")?
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 month86400, one day: line 24, revalidate: 60 * 60 * 24. Its expire is a week, and its stale five minutes.
When you set a deployment id, what does Next.js put in the key's last part instead of the build's id?
const buildId = workStore.deploymentId || workStore.buildId
// …
const hmrRefreshHash = getHmrRefreshHash(workUnitStore)
// …
return {
implementationPart: hmrRefreshHash
? [buildId, hmrRefreshHash]
: [buildId],
runtimeEnvVarStateHash: undefined,deploymentId. Line 1 is workStore.deploymentId || workStore.buildId: with a deployment id set, every build of that deployment shares it in the key.
Under next start, how many calls does the cache handler get for the first visit to /today?
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>
);
}
None. getMenu("usd") ran during the build's prerender of this route, so the entry is in /today's resume data cache; the resume reads it there, and the recorded next start log shows no call for /today.
After revalidateTag("menu", "max"), what does this get return for the menu's entry?
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
}The entry, with revalidate set to -1 (lines 21 to 24). Next.js serves it and makes a new one in the background. The recording printed found, revalidate -1.
Which handler method tells Next.js when a request's soft tags were last revalidated?
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>
}getExpiration. Next.js calls it with the request's soft tags and compares the time with each entry's timestamp; that's how revalidatePath reaches the menu.
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.
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.
Question 2 of 7Which three parts make a
'use cache'key in a production build?Choose an answer to see the explanation.
Question 3 of 7Where do the built-in
cacheLifeprofiles' numbers come from?Choose an answer to see the explanation.
Question 4 of 7In production, what does the built-in handler's
getdo with an entry past itsrevalidatetime?Choose an answer to see the explanation.
Question 5 of 7Why did
/today, a◐route, make no cache handler calls at all undernext start?Choose an answer to see the explanation.
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.
Question 7 of 7How does
revalidatePath("/menu")reach an entry whose only tag ismenu?Choose an answer to see the explanation.
Key takeaways
- The compiler turns a
"use cache"function into a call tocache(kind, id, boundArgs, fn, args), inside React'scache. - The key is
[id, args, [buildId]], encoded withencodeReply; 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;
cacheLifekeeps the shortest time, and the entry carries it. - The built-in handler is a 50 MB LRU in each server's memory, shared by
defaultandremote; in production it drops entries afterrevalidate. 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.
updateTagexpires now,revalidateTagwith a profile marks stale, andrevalidatePathexpires 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.