Caching in Depth: ISR, Response Cache, Cache Handlers, and the `use cache` Directive
Prerequisites
- ›Articles 1-3: Architecture, server lifecycle, and rendering engine
- ›Incremental Static Regeneration (ISR) concepts — stale-while-revalidate pattern
- ›HTTP caching fundamentals — Cache-Control headers, revalidation
- ›Understanding of PPR from Article 3
Caching in Depth: ISR, Response Cache, Cache Handlers, and the use cache Directive
Caching in Next.js isn't a single system — it's five interrelated systems working at different layers of the stack. Understanding which cache does what, and how they interact, is critical for both debugging cache behavior and building correct applications. This article maps the entire caching architecture, from the in-memory response cache that prevents thundering herds to the client-side segment cache that enables instant PPR navigations.
Cache Architecture Overview
Before diving into details, let's establish the mental model:
flowchart TD
Request["HTTP Request"] --> ResponseCache["Response Cache\n(in-memory, per-server)"]
ResponseCache -->|Miss| IncrementalCache["Incremental Cache\n(filesystem/custom)"]
IncrementalCache -->|Miss| Render["Render Pipeline"]
Render --> UseCacheHandler["CacheHandler\n('use cache' directive)"]
UseCacheHandler --> DefaultHandler["Default Handler\n(in-memory LRU)"]
UseCacheHandler --> RemoteHandler["Remote Handler\n(custom provider)"]
Render --> FlightData["RSC Flight Data"]
FlightData --> SegmentCache["Segment Cache\n(client-side, per-segment)"]
Render --> ResumeDataCache["Resume Data Cache\n(PPR postponed state)"]
subgraph "Server-Side Caches"
ResponseCache
IncrementalCache
UseCacheHandler
DefaultHandler
RemoteHandler
ResumeDataCache
end
subgraph "Client-Side Caches"
SegmentCache
end
Each layer serves a distinct purpose:
| Cache | Scope | Purpose |
|---|---|---|
| Response Cache | Per-server, in-memory | Deduplicate concurrent requests |
| Incremental Cache | Persistent (filesystem) | ISR page persistence across restarts |
| CacheHandler | Pluggable (default: LRU) | use cache directive storage |
| Resume Data Cache | Per-render | PPR postponed state for resumption |
| Segment Cache | Client-side, per-segment | PPR prefetch data for navigation |
Response Cache: In-Memory Deduplication
The response-cache/index.ts implements the first layer of caching. Its primary job is preventing thundering herd — when 100 concurrent requests arrive for the same page, only one should actually render it.
The response cache uses a Batcher utility (a coalescing mechanism) combined with an LRU cache:
sequenceDiagram
participant R1 as Request 1
participant R2 as Request 2
participant R3 as Request 3
participant RC as Response Cache
participant Render as Render Pipeline
R1->>RC: GET /products
RC->>Render: Render /products (cache miss)
R2->>RC: GET /products (concurrent)
RC-->>R2: Coalesced (waiting for R1's render)
R3->>RC: GET /products (concurrent)
RC-->>R3: Coalesced (waiting for R1's render)
Render-->>RC: RenderResult
RC-->>R1: Response
RC-->>R2: Response (same result)
RC-->>R3: Response (same result)
The cache key strategy includes the pathname and, in minimal mode (deployed environments), an invocation ID — this prevents stale cache entries from leaking across different invocations. The default maximum cache size is 150 entries, configurable via NEXT_PRIVATE_RESPONSE_CACHE_MAX_SIZE, with a TTL of 10 seconds (NEXT_PRIVATE_RESPONSE_CACHE_TTL).
The response cache is intentionally short-lived. It's not meant to persist across server restarts — that's the incremental cache's job. Its value is purely in deduplication during high-concurrency scenarios.
Incremental Cache: ISR Persistence
The incremental cache (in server/lib/incremental-cache/) is the backbone of Incremental Static Regeneration. It persists rendered pages to the filesystem so they survive server restarts and can be served without re-rendering.
Each cache entry stores:
- The rendered HTML
- The RSC Flight data
- Route metadata (headers, status code)
- Cache control information (revalidate timing, expiration)
The cache control system is defined in cache-control.ts:
export interface CacheControl {
revalidate: Revalidate // number | false
expire: number | undefined
}
export function getCacheControlHeader({ revalidate, expire }: CacheControl): string {
const swrHeader =
typeof revalidate === 'number' && expire !== undefined && revalidate < expire
? `, stale-while-revalidate=${expire - revalidate}`
: ''
if (revalidate === 0) {
return 'private, no-cache, no-store, max-age=0, must-revalidate'
} else if (typeof revalidate === 'number') {
return `s-maxage=${revalidate}${swrHeader}`
}
return `s-maxage=${CACHE_ONE_YEAR_SECONDS}${swrHeader}`
}
flowchart TD
Request["Request for /blog/post-1"] --> ISRCheck{"Cache entry\nexists?"}
ISRCheck -->|No| Render["Full Render"]
ISRCheck -->|Yes| FreshCheck{"Entry fresh?\n(within revalidate period)"}
FreshCheck -->|Yes| ServeCache["Serve from cache"]
FreshCheck -->|No| StaleCheck{"Entry stale?\n(within expire period)"}
StaleCheck -->|Yes| SWR["Serve stale +\nBackground revalidate"]
StaleCheck -->|No| Render
Render --> Store["Store in cache"]
SWR --> BGRender["Background Render"]
BGRender --> Store
The revalidate field controls the stale-while-revalidate window. When set to 60, the page is fresh for 60 seconds, then stale (served while revalidating in the background). The expire field controls the maximum age — after this, the stale entry is no longer served and a full render is required.
On-demand revalidation via revalidatePath() and revalidateTag() works by marking cache entries as stale immediately, regardless of their timing-based expiration.
CacheHandler Interface and the use cache Directive
The CacheHandler interface is the abstraction that powers the use cache directive. It's a clean, well-defined contract:
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>
}
classDiagram
class CacheHandler {
<<interface>>
+get(cacheKey, softTags) Promise~CacheEntry~
+set(cacheKey, pendingEntry) Promise~void~
+refreshTags() Promise~void~
+getExpiration(tags) Promise~Timestamp~
+updateTags(tags, durations) Promise~void~
}
class DefaultCacheHandler {
-memoryCache: LRUCache
-pendingSets: Map
+get(cacheKey, softTags)
+set(cacheKey, pendingEntry)
}
class CustomHandler {
+get(cacheKey, softTags)
+set(cacheKey, pendingEntry)
+refreshTags()
}
CacheHandler <|.. DefaultCacheHandler
CacheHandler <|.. CustomHandler
The CacheEntry type is designed for streaming — the value field is a ReadableStream<Uint8Array>, not a string or buffer. This means cache entries can be written before they're fully computed. The set() method receives a Promise<CacheEntry> — the handler must wait for the promise to resolve before the entry is complete. If get() is called for the same key before set() finishes, the handler should wait for set() to complete rather than returning undefined.
The default handler uses an in-memory LRU cache. The comment at the top of the file is instructive: "In-memory caches are fragile and should not use stale-while-revalidate semantics... because it's not worth warming up an entry that's likely going to get evicted before we get to use it anyway."
The cache handler registration happens in use-cache/handlers.ts. Handlers are stored on globalThis using Symbols to ensure they work across module boundaries. The initializeCacheHandlers() function checks for user-provided handlers via the @next/cache-handlers Symbol, falling back to the default LRU handler.
Tip: To implement a custom cache handler (e.g., Redis-backed), you implement the
CacheHandlerinterface and register it via the global Symbol before the server starts. Theexamples/cache-handler-redisexample in the repo shows this pattern.
Tag-Based Revalidation
Tags are the mechanism for invalidating cache entries by semantic category rather than by exact URL. There are two types: implicit tags (derived automatically from routes) and explicit tags (added via cacheTag()).
The implicit-tags.ts module derives tags from route paths:
const getDerivedTags = (pathname: string): string[] => {
const derivedTags: string[] = [`/layout`]
if (pathname.startsWith('/')) {
const pathnameParts = pathname.split('/')
for (let i = 1; i < pathnameParts.length + 1; i++) {
let curPathname = pathnameParts.slice(0, i).join('/')
if (curPathname) {
if (!curPathname.endsWith('/page') && !curPathname.endsWith('/route')) {
curPathname = `${curPathname}${
!curPathname.endsWith('/') ? '/' : ''
}layout`
}
derivedTags.push(curPathname)
}
}
}
return derivedTags
}
For a page at /dashboard/analytics/page, the derived tags would be:
/layout/dashboard/layout/dashboard/analytics/page
flowchart TD
Revalidate["revalidatePath('/dashboard')"] --> FindTag["Match tag:\n/dashboard/layout"]
FindTag --> InvalidateAll["Invalidate all entries\ntagged with /dashboard/layout"]
InvalidateAll --> Entry1["/dashboard/analytics\n(has tag /dashboard/layout)"]
InvalidateAll --> Entry2["/dashboard/settings\n(has tag /dashboard/layout)"]
InvalidateAll --> Entry3["/dashboard\n(has tag /dashboard/layout)"]
This means calling revalidatePath('/dashboard') invalidates not just the dashboard page, but all pages under /dashboard/* — because they all share the /dashboard/layout implicit tag. This is the mechanism behind layout-level revalidation.
The ImplicitTags interface uses lazy results for expirations — tag staleness is only computed when a cache entry is actually read. This optimization avoids unnecessary work for requests that don't hit any caches.
PPR Caches: Resume Data and Segment Cache
PPR introduces two specialized caches that don't exist in the traditional rendering pipeline.
Resume Data Cache (Server-Side)
The resume-data-cache/ stores the state captured during static prerendering that's needed to complete a postponed render at request time. As we saw in Article 3, when a Server Component calls headers() during prerendering, React postpones that Suspense boundary. The resume data cache stores:
- RSC payload fragments already rendered for the static shell
- Fetch cache entries (responses from
fetch()calls made during prerendering) - Serialized postpone state (what boundaries were postponed and why)
When a request arrives for a PPR page, the server loads the static shell from the incremental cache and the resume data from the resume data cache, then calls React's resume() to complete the render.
Segment Cache (Client-Side)
As detailed in Article 4, the segment cache stores prefetched static shells for PPR-enabled routes on the client. When a <Link> to a PPR page becomes visible, the router prefetches the static shell segments and caches them. On navigation, these cached segments provide instant visual feedback while the dynamic holes are fetched.
flowchart TD
subgraph "Build Time"
Prerender["Prerender PPR page"] --> StaticShell["Static Shell\n(HTML + RSC)"]
Prerender --> ResumeData["Resume Data\n(postponed state)"]
StaticShell --> IncrCache["Incremental Cache"]
ResumeData --> ResumeCache["Resume Data Cache"]
end
subgraph "Client Prefetch"
LinkVisible["<Link> visible"] --> PrefetchReq["Prefetch request"]
PrefetchReq --> Server["Server serves\nstatic shell"]
Server --> SegCache["Segment Cache\n(client-side)"]
end
subgraph "Request Time"
Navigate["User navigates"] --> ServeShell["Serve static shell\n(from segment cache)"]
ServeShell --> DynamicReq["Fetch dynamic data"]
DynamicReq --> ServerResume["Server resumes\npostponed render"]
ServerResume --> DynamicData["Dynamic content\nstreamed to client"]
DynamicData --> Merge["Merge into page"]
end
Development vs. Production Caching
Cache behavior differs significantly between environments:
| Behavior | Development | Production |
|---|---|---|
| Response cache | Disabled (every request renders) | Enabled with LRU + TTL |
| Incremental cache | Enabled but frequently invalidated | Full ISR with SWR |
use cache handler |
Default LRU (small) | Configurable (LRU or custom) |
| Static prerendering | On-demand (first request) | At build time |
| Segment cache | Active (for testing PPR) | Active |
| Resume data cache | Used in dev PPR mode | Used for deployed PPR |
In development, most caching is intentionally weakened to ensure developers see fresh content. The response cache is effectively disabled — each request triggers a fresh render. The incremental cache still operates (you can test ISR in dev), but file watchers trigger invalidation on source changes.
Tip: If you're debugging caching issues in production, enable
NEXT_PRIVATE_DEBUG_CACHE=1as an environment variable. The default cache handler and theuse-cachehandler both check this variable and log detailed cache hit/miss information to the console.
Wrapping Up the Series
Over these six articles, we've traced the complete architecture of Next.js — from the monorepo's 19 packages and Rust crates (Article 1), through the layered server boot sequence (Article 2), into the RSC rendering engine with streaming and PPR (Article 3), across to the client-side router's state machine (Article 4), through the three-compiler build pipeline (Article 5), and finally through the multi-layered caching system (Article 6).
The recurring theme across all these systems is layered abstraction with shared contracts. The route module system abstracts over rendering strategies. The manifest format abstracts over bundlers. The cache handler interface abstracts over storage backends. The AsyncLocalStorage pattern abstracts over prop threading. Each layer adds complexity, but also flexibility — and that flexibility is what allows Next.js to support Pages Router and App Router, webpack and Turbopack and Rspack, Node.js and Edge, all within a single framework.
The codebase rewards careful reading. The files are large, but they're large because they keep related logic together. Follow a single request through the system, and the architecture reveals itself.