Testing Server Components that call cookies()/headers() outside request context, AsyncLocalStorage-aware test wrappers, mocking the Data Cache layer for deterministic tests, testing optimistic UI race conditions, E2E with Playwright, and the five test patterns that catch real production bugs.
P-6 — Caching in Practice: The Four-Layer Model and the use cache Directive
Who this is for: Practitioners who have built Next.js applications and have a vague sense that caching is complex, but haven't yet developed the mental model to predict what will or won't be cached, why a page is rendering dynamically when it shouldn't, or why a page is serving stale data when it should be fresh. This module builds that mental model completely — and covers the new
use cachedirective that Next.js 15 introduced to make the old model obsolete.
Why This Is the Hardest Topic in Next.js
Caching in Next.js has a reputation for being confusing. The reputation is earned. As of Next.js 15, there are four distinct caching layers operating simultaneously, each with its own lifecycle, scope, and invalidation mechanism. Bugs in one layer look like bugs in another. Features that work in development behave differently in production. The build output shows ○ but the page is serving stale data.
The engineers who master Next.js are the ones who have a clear mental model of these four layers and can look at a bug or a performance problem and immediately know which layer it belongs to.
The Four Layers
| Layer | Where | Scope | Duration | Invalidation |
|---|---|---|---|---|
| Request Memoization | React render | Single request | Ends with request | Automatic (per request) |
| Data Cache | Next.js server | Cross-request | Configurable / indefinite | revalidateTag, revalidatePath, time-based |
| Full Route Cache | CDN / Next.js | Cross-request | Indefinite (static) | revalidatePath, revalidateTag, redeploy |
| Router Cache | Browser | Current session | 30s–5min | router.refresh(), navigation |
Each layer caches a different thing at a different granularity. Understanding what each layer holds is the key.
Layer 1: Request Memoization
What it caches: The return values of fetch() calls and React.cache()-wrapped functions, within a single server render.
Scope: A single HTTP request lifecycle. Starts when the render begins, ends when the response is sent.
Purpose: Prevents duplicate data fetching within the same render tree. If ComponentA and ComponentB both call fetch('/api/user/123') during the same page render, the fetch happens once. The second call gets the memoised result.
Important: Request Memoization only applies to fetch(). For database queries, use React.cache() (covered in F-4 and P-1) to get the same deduplication behaviour.
This layer is automatic and cannot be configured. You cannot extend it beyond a single request, and you don't need to — that's what the Data Cache is for.
Layer 2: The Data Cache
What it caches: The results of fetch() calls with caching options, and the results of unstable_cache() / use cache functions.
Scope: Persists across requests, across deployments (unless explicitly invalidated), across serverless function invocations.
Duration: Until invalidated by revalidateTag, revalidatePath, time-based expiry, or a full redeployment.
This is the layer you actively configure. In Next.js 14, you configured it via fetch() options. In Next.js 15, the preferred approach is the use cache directive.
The Old Model: fetch() Cache Options
This model works but has a limitation: it only applies to fetch(). Database queries via Prisma, ORM calls, or any non-HTTP data source aren't covered.
The New Model: use cache (Next.js 15+)
use cache is a directive — like 'use server' and 'use client' — that makes a function's return value cacheable, regardless of how it fetches data:
use cache can also be applied at the file level (all exports cached) or at the component level:
When applied to a Server Component, the rendered output (the RSC payload) is cached — not just the data. This is more powerful than caching data: you cache the entire rendered result.
cacheLife() — TTL Profiles
By default, use cache caches indefinitely. cacheLife() sets the Time To Live:
Built-in profiles:
'seconds'— stale after 15s, max 60s'minutes'— stale after 1 min, max 10 min'hours'— stale after 1 hr, max 1 day'days'— stale after 1 day, max 1 week'weeks'— stale after 1 week, max 1 month'max'— stale after 1 year (effectively indefinite)
Custom profiles:
The three values map to cache-control semantics: stale = how long the browser/CDN can serve stale without revalidating, revalidate = when to trigger background revalidation (ISR), expire = hard TTL after which the cache entry is deleted entirely.
Define custom profiles in next.config.ts for consistent reuse:
cacheTag() and revalidateTag() — On-Demand Invalidation
When a product is updated, invalidate only the affected entries:
The invalidation is precise: a single product update doesn't bust the cache for every product — only the affected product and any aggregate views that include it.
Layer 3: The Full Route Cache
What it caches: The complete rendered output (HTML + RSC payload) for static routes.
Scope: Persists across requests, stored at the CDN or Next.js server level.
Duration: Indefinite for static routes. ISR routes revalidate based on their revalidate config. Dynamic routes don't cache.
This is what makes ○ (static) routes fast: the first request renders the page; subsequent requests serve the pre-rendered HTML from the CDN without the Next.js server doing any work.
What opts a route out of the Full Route Cache:
- Accessing
cookies(),headers(), orconnection()anywhere in the render chain - Using
fetch()withcache: 'no-store' - Using
use cachewithout a long-enoughcacheLife(if the function itself is dynamic, the route is dynamic) - Accessing
searchParamsin the page component - Calling
unstable_noStore()
When a route becomes dynamic (λ in the build output), every request triggers a full server render. The Full Route Cache is bypassed entirely.
Revalidating the Full Route Cache:
Note: revalidateTag invalidates both the Data Cache and the Full Route Cache for routes that depend on that tag. This is the correct way to invalidate after a mutation — one call covers both layers.
Layer 4: The Router Cache
What it caches: Rendered RSC payloads for routes the user has visited or that have been prefetched, stored in browser memory.
Scope: Client-side, current browser session only. Not shared across tabs, not persisted across reloads.
Duration: 30 seconds for dynamic routes, 5 minutes for static routes (configurable).
Purpose: Makes client-side navigation feel instant. When you click a <Link>, Next.js first checks the Router Cache. If the route's RSC payload is cached there, it renders immediately — no network request needed.
The stale data problem: The Router Cache is where "why am I seeing old data after a mutation?" bugs live. You update a product via a Server Action. The server-side caches are invalidated. But the user's browser Router Cache still has the old RSC payload. They navigate to the product page and see the old data.
Solutions:
staleTimes.dynamic = 0 disables Router Cache for dynamic routes — every navigation fetches a fresh RSC payload. This eliminates the stale data problem at the cost of slightly slower navigation. For dashboards, user feeds, or any content that changes frequently, dynamic: 0 is usually the right trade-off.
How the Layers Interact
Walking through a request for /products/shoes:
-
Request arrives. Next.js checks the Full Route Cache for
/products/shoes.- If cached (
○): returns the pre-rendered HTML immediately, skipping all rendering. - If not cached (
λ): proceeds to render.
- If cached (
-
Server renders the page. Server Components execute. Each data call goes through:
- Request Memoization: if the same fetch URL has been called before in this render, return the memoised result.
- Data Cache: if this fetch has a cached entry (from
use cacheorfetch()+force-cache), return it without hitting the origin. If not, fetch from origin and store.
-
Response sent. HTML and RSC payload delivered to the browser.
-
Browser caches. The RSC payload is stored in the Router Cache for the configured staleTimes duration. Subsequent navigations to
/products/shoesuse this cache.
When a mutation happens:
revalidateTag('products')→ clears Data Cache entries tagged'products'and Full Route Cache entries for routes that used that data.router.refresh()→ clears the Router Cache for the current route in the browser.
Both are typically needed after a mutation that should be immediately visible: revalidateTag on the server, router.refresh() on the client.
The use cache vs unstable_cache Decision
unstable_cache still works in Next.js 15 and will continue to work. Use use cache for new code:
unstable_cache | use cache | |
|---|---|---|
| Caches | Function return values | Function return values + Server Component output |
| Setup | Wrap function | Add directive |
| Works with | Any async function | Any async function + Server Components |
| Cache key | Manual array | Automatically derived from closure |
| Profiles | revalidate + tags | cacheLife() + cacheTag() |
| Status | Stable, maintained | Experimental (Next.js 15), stabilising |
For new Practitioner-level code: use use cache. For existing codebases on Next.js 14 or mixed codebases where you need certainty: unstable_cache.
Practical Caching Architecture
The pattern that works for most production applications:
Where We Go From Here
P-7 covers SEO, metadata, and sitemaps — the full metadata API including dynamic Open Graph images with ImageResponse, generateSitemaps() for large catalogs, generateViewport(), and draftMode() for CMS preview.
The caching model from this module is foundational for the Architect phase. A-1 (RSC internals) explains why these layers exist at the protocol level. A-3 (Partial Prerendering) shows how Next.js uses the boundary between static and dynamic content to serve part of a page from the Full Route Cache while streaming dynamic holes from the server. The four layers you've learned here are the infrastructure that makes PPR possible.
In the Next.js 15 four-layer caching model, which layer prevents duplicate data fetching within the same render tree and only lasts for the duration of a single request?
How does the new use cache directive differ from using unstable_cache in Next.js 15?
What Next.js configuration is commonly used to disable the Router Cache for dynamic routes to prevent the "stale data problem" on the client side after mutations?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion