Nearly every widely-known 'four cache layers' explanation of Next.js caching describes a model that is now explicitly the Previous Model. Cache Components — "use cache" plus cacheLife/cacheTag/updateTag/revalidateTag — is the current one, and it answers the exact same question (what's cached, for how long, and how do I invalidate it on demand) with one directive instead of four separate systems to reason about.
The beginner framing: caching stores the result of expensive work — a database query, a slow API call, a whole rendered page — so a future request for the same thing can be served instantly instead of redoing that work.
The precise mental model: Cache Components (cacheComponents: true in next.config.ts) is the current caching model, built around one directive: "use cache", placed at the top of a file, a component, or a function, marking its return value as cacheable.
// Function-level — caches just this function's return valueimport { cacheLife } from "next/cache";export async function getProducts() { "use cache"; cacheLife("hours"); // stale: 5m, revalidate: 1h, expire: 1d return db.query("SELECT * FROM products");}
// Component-level (UI-level caching) — caches the rendered outputexport async function BlogPosts() { "use cache"; const posts = await db.query("SELECT * FROM posts"); return <ul>{posts.map((p) => <li key={p.id}>{p.title}
"use cache" lifecycle: miss → compute → hit → stale → expire
First request for getProducts(). No cache entry exists under this function's cache key (Build ID + Function ID + serialized arguments), so Next.js must actually execute the function body.
Cache keys: what actually makes two calls "the same"#
A cache entry's key is built from: the Build ID (unique per build — a redeploy invalidates everything), the Function ID (a hash of the function's location and signature), its serialized arguments, and — this is the detail that surprises people — any variables captured from an outer scope, which are automatically bound in as if they were arguments too.
async function Component({ userId }: { userId: string }) { const getData = async (filter: string) => { "use cache"; // cache key includes BOTH userId (captured from closure) AND filter (an argument) return fetch(`/api/users/${userId}/data?filter=${filter}`); }; return
cacheLife sets how long a cached entry stays valid, via built-in profiles or a custom object:
Profile
stale
revalidate
expire
seconds
0
1s
60s
minutes
5m
1m
1h
hours
cacheTag lets you invalidate on demand instead of (or alongside) waiting for time to pass:
cacheTag + updateTag/revalidateTag: invalidating by tag, not by guessing paths
step 1 / 4
export async function getProducts() {
'use cache'; cacheTag('products');
return db.query('SELECT * FROM products');
}
export async function getFeaturedProducts() {
'use cache'; cacheTag('products'); // SAME tag
return db.query('...WHERE featured = true');
}
getProducts()HIT
tag: products
getFeaturedProducts()HIT
tag: products
Two different cached functions, tagged with the same 'products' tag. They're cached independently, but both can be invalidated together by that shared tag.
updateTag
revalidateTag
Where
Server Actions only
Server Actions and Route Handlers
Behavior
Immediately expires the cache
Stale-while-revalidate (serves stale, refreshes in background)
Use case
Read-your-own-writes
Background refresh, slight delay acceptable
revalidatePath invalidates everything cached for a specific route — useful when you don't know (or don't want to enumerate) every tag involved, though tag-based invalidation is generally more precise.
How rendering actually works: a static shell around dynamic holes#
<ProductInfo /> {/* 'use cache' — part of the shell */}
<Suspense fallback={<Skeleton />}>
<LiveInventory /> {/* reads runtime data — a DYNAMIC HOLE */}
</Suspense>
</Layout>
LayoutHIT
static — part of the prerendered shell
ProductInfo ('use cache')HIT
static — part of the prerendered shell
LiveInventory (runtime data)DYNAMIC HOLE
left OUT of the shell — a dynamic hole
At build time (or first request), Next.js prerenders everything it safely can into a static shell. Anything reading runtime-only data (cookies, live stock counts) and wrapped in Suspense becomes a 'dynamic hole' — deliberately excluded from the shell.
Cache Components' rendering model prerenders everything it safely can — layouts, "use cache" content — into a static shell, served instantly. Anything reading runtime APIs (cookies(), headers(), searchParams, params without generateStaticParams) must be wrapped in <Suspense>, becoming a dynamic hole that streams in separately, exactly as covered in Data Fetching and Suspense.
Non-deterministic operations (Math.random(), Date.now(), crypto.randomUUID()) need explicit handling — either await connection() before them plus a <Suspense> wrap (a fresh value every request), or deliberately cache the result (the same value for everyone, until revalidation).
The previous model — still asked about in interviews#
⚠️ The pre-16 model (still asked in interviews) — superseded by Cache Components
step 1 / 4
Previous model (pre-Next 16) — still commonly asked about in interviews, but superseded by Cache Components ("use cache" + cacheLife/cacheTag) shown in the other presets.
// Request Memoization — same render pass
async function Header() { return fetch('/api/user'); }
async function Sidebar() { return fetch('/api/user'); } // SAME call
fetch('/api/user') — this render passHIT
deduplicated automatically — ONE network call for both callers
Layer 1, Request Memoization: identical fetch() calls within the SAME render pass are automatically deduplicated — calling it from multiple components costs one network request, not two.
Before Cache Components, caching was spread across four separate, implicit layers — the model most existing tutorials and interview questions still describe. It's worth fluency in both.
Predict what happens before checking the solution.
import { Suspense } from "react";import { cookies } from "next/headers";const cache = new Map<string, Promise<string>>();export default function Page() { return ( <> <Suspense fallback={<
What happens when this builds?
Solution
The build hangs, eventually failing after a 50-second timeout with an error like "Filling a cache during prerender timed out." Dynamic stores a genuinely runtime-dependent promise (a live fetch) into a plain, shared Map; Cached, a "use cache" scope, then retrieves and awaits that same promise via the shared Map — but a cached function can never resolve a promise representing runtime-only data during prerendering, since that data structurally can't exist yet at build time. The fix: don't route runtime-dependent promises through shared storage that a cached function then reads from — extract the runtime value in the non-cached component and pass it as a genuine argument to the cached function instead.
Build a simplified cacheLife expiry checker, modeling the stale/revalidate/expire windows:
function checkCacheStatus(cachedAt, now, profile) { const age = now - cachedAt; if (age < profile.stale) return "hit"; // fully fresh, instant if (age < profile.revalidate) return "stale-but-servable"; // serve stale, maybe refresh if (age < profile.expire) return "stale-triggers-background-refresh"
This mirrors cacheLife's actual three-tier model: a request's experience depends entirely on which window the entry's current age falls into, not a single binary cached/uncached state.
The static-shell-plus-dynamic-holes rendering model is the Suspense mechanism, applied at the whole-application level — a "dynamic hole" is just a component that threw a promise (because it's waiting on a genuinely runtime-dependent value), caught by its <Suspense> boundary, exactly like any other Suspense usage. And a cache entry's key — built from function identity plus serialized arguments plus captured closure variables — is conceptually a hash map lookup from Data Structures, where the "key" happens to be a composite of several pieces of information rather than a single value.
1. Calling cookies()/headers() directly inside a "use cache" scope#
async function getContent() { "use cache"; const theme = (await cookies()).get("theme"); // ❌ fails IMMEDIATELY, not a timeout}
This fails right away with a specific, documented error — runtime APIs are structurally forbidden inside a cached scope. Read them outside the cache boundary and pass the extracted value in as an argument.
2. Leaking a runtime-data promise into a cached scope via props, closures, or shared storage#
Covered in Try It — this is subtler than a direct cookies() call, and instead of an immediate error, it causes a 50-second build timeout, since the cached function is waiting on a promise that can't possibly resolve during prerendering.
3. Assuming React.cache values are visible inside a "use cache" scope#
const store = cache(() => ({ current: null }));function Parent() { store().current = "value"; // set OUTSIDE a cache scope return <Child />;}async function Child() { "use cache"; return <div>{store().current}</
"use cache" functions have their own, isolated React.cache scope — values set outside are never visible inside. Pass data as genuine function arguments instead.
4. Forgetting file-level "use cache" requires every export to be async#
Placing "use cache" at the top of a file marks every export as cacheable — and every one of them must be an async function, or the file won't compile as expected.
Read runtime values (cookies, headers) outside any cache boundary and pass them in as arguments — this is the single rule that avoids both the immediate-error and the 50-second-timeout failure modes.
Prefer tag-based revalidation (cacheTag + updateTag/revalidateTag) over path-based (revalidatePath) — it's more precise and avoids over-invalidating unrelated cached content.
Choose updateTag specifically for read-your-own-writes UX, and revalidateTag when a brief, background-refreshed delay for other users is acceptable.
Set NEXT_PRIVATE_DEBUG_CACHE=1 during development to get verbose logging when cache behavior isn't matching expectations.
before relying on — it's unsupported under static export, and runtime persistence differs meaningfully between serverless and self-hosted deployments.
In serverless environments, in-memory "use cache" entries may not persist across requests (each request can hit a different instance) — for durable, shared caching across instances, 'use cache: remote' lets a platform provide a dedicated cache handler (Redis, a KV store), at the cost of a network round-trip and typically platform fees.
Be deliberate about what a cached function's closure captures — every captured outer-scope variable becomes part of the cache key, and an unnecessarily wide capture can silently fragment your cache into far more entries than intended.
revalidateTag's stale-while-revalidate behavior is usually the better default for content where a brief delay is acceptable (catalogs, blog content) — reserve updateTag's immediate invalidation for cases where the user must see their own change instantly.
</
li
>)
}
</
ul
>;
}
getData
(
"active"
);
}
5m
1h
1d
days
5m
1d
1w
weeks
5m
1w
30d
max
5m
30d
~indefinite
next.tags on fetch + revalidateTag/revalidatePath
cacheTag + updateTag/revalidateTag/revalidatePath (same function names, new source)
React.cache for request-scoped dedup of non-fetch sources
Still valid and used the same way — unrelated to "use cache"'s own separate caching