DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/Next.js/Caching (Cache Components + legacy model)
advanced35 min read·Updated Jun 2025

Caching (Cache Components + legacy model)

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.

cachingcache-componentsuse-cachecachelifecachetagrevalidationflagship

Knowledge Check

20 questions · pass at 70%

0/20
easymcq

1. What single directive is Cache Components built around?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
CACHE COMPONENTS (current model): cacheComponents: true in next.config.
  "use cache" — file/component/function level. File-level requires
  ALL exports to be async.

CACHE KEY = Build ID + Function ID + serialized args +
  CAPTURED CLOSURE VARIABLES (+ HMR hash in dev).

cacheLife(profile | {stale, revalidate, expire}):
  seconds/minutes/hours/days/weeks/max — default: stale 5m,
  revalidate 15m, expire never (by time).

cacheTag('name') + invalidation:
  updateTag()     → IMMEDIATE expiry. Server Actions ONLY.
                     Read-your-own-writes.
  revalidateTag()  → stale-while-revalidate. Actions + Route Handlers.
                     Background refresh, slight delay OK.
  revalidatePath()  → invalidate by ROUTE, less precise than tags.

RENDERING MODEL: static shell (cacheable content, prerendered) +
  dynamic holes (<Suspense>-wrapped runtime API access: cookies(),
  headers(), searchParams, unlisted params).

Non-deterministic ops (Math.random, Date.now, crypto.randomUUID):
  await connection() + Suspense (fresh per request), OR deliberately
  cache the result (same value until revalidation).

CONSTRAINTS:
  - cookies()/headers() DIRECTLY inside "use cache" → immediate error.
  - Runtime-data PROMISE leaking in via props/closure/shared Map →
    ~50s BUILD HANG ("Filling a cache during prerender timed out").
  - React.cache is ISOLATED inside "use cache" — values set outside
    are NOT visible inside. Pass data as arguments instead.
  - Draft Mode → all cached functions re-execute every request.
  - NOT supported on static export.

PREVIOUS MODEL (still interview-relevant):
  fetch(url,{cache:'force-cache'})  ≈ "use cache" wrapping a fetch
  unstable_cache() (non-fetch)       ≈ "use cache" on any async fn
  route segment config (dynamic/    ≈ cacheLife + Suspense around
    fetchCache/revalidate)             runtime access
  next.tags + revalidateTag/Path     ≈ cacheTag + updateTag/
                                        revalidateTag/revalidatePath

Related topics

Data Fetching & Mutations (App Router)ProSuspensePro

On this page

35m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

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 value
import { 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 output
export 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
step 1 / 5
import { cacheLife } from 'next/cache';
export async function getProducts() {
'use cache';
cacheLife('hours'); // stale: 5m, revalidate: 1h, expire: 1d
return db.query('SELECT * FROM products');
}
getProducts()MISS
no cache entry exists yet — first request ever

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

Revalidation: cacheLife, cacheTag, updateTag, revalidateTag#

cacheLife sets how long a cached entry stays valid, via built-in profiles or a custom object:

Profilestalerevalidateexpire
seconds01s60s
minutes5m1m1h
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.

updateTagrevalidateTag
WhereServer Actions onlyServer Actions and Route Handlers
BehaviorImmediately expires the cacheStale-while-revalidate (serves stale, refreshes in background)
Use caseRead-your-own-writesBackground 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#

Static shell + streamed dynamic holes — Cache Components' rendering model
step 1 / 4
<Layout> {/* cached — part of the shell */}
<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.

Legacy conceptCurrent equivalent
fetch(url, { cache: 'force-cache' })"use cache" wrapping the fetch
unstable_cache() for non-fetch functions"use cache" on any async function
Route segment config (dynamic, fetchCache, revalidate)cacheLife + Suspense boundaries around runtime access

Try It#

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.


Implement It Yourself#

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.


Under the Hood#

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.


Common Mistakes#

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.


Best Practices#

  • 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.

Performance Tips#

  • 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
days5m1d1w
weeks5m1w30d
max5m30d~indefinite
next.tags on fetch + revalidateTag/revalidatePathcacheTag + updateTag/revalidateTag/revalidatePath (same function names, new source)
React.cache for request-scoped dedup of non-fetch sourcesStill valid and used the same way — unrelated to "use cache"'s own separate caching
div
>Loading...</
div
>
}
>
<Dynamic id="data" />
</Suspense>
<Cached id="data" />
</>
);
}
async function Dynamic({ id }: { id: string }) {
cache.set(id, fetch(`https://api.example.com/${id}`).then((r) => r.text()));
return <p>Dynamic</p>;
}
async function Cached({ id }: { id: string }) {
"use cache";
return <p>{await cache.get(id)}</p>;
}
;
// serve stale, DEFINITELY refresh in background
return "expired"; // must recompute synchronously, like a fresh miss
}
const hoursProfile = { stale: 5 * 60_000, revalidate: 60 * 60_000, expire: 24 * 60 * 60_000 };
checkCacheStatus(0, 2 * 60_000, hoursProfile); // "hit" — 2 min old, within stale window
checkCacheStatus(0, 30 * 60_000, hoursProfile); // "stale-but-servable" — 30 min old
checkCacheStatus(0, 2 * 60 * 60_000, hoursProfile); // "stale-triggers-background-refresh" — 2h old
checkCacheStatus(0, 25 * 60 * 60_000, hoursProfile); // "expired" — 25h old, past the 24h expire window
div
>;
// ❌ always null — ISOLATED scope
}
Check the platform support table
"use cache"