Deep Dive: Next.js Four-Layer Cache System
Why the Caching System Is This Complex
The Next.js caching documentation was, for a time, one of the most complained-about pages in any JavaScript framework's docs — a diagram with four cache layers, bidirectional arrows, and color-coded boxes left many developers confused or intimidated. But this complexity is not overengineering. It is the honest surface area of a system trying to simultaneously satisfy genuinely competing requirements: maximum performance (serve pre-rendered static content), adequate freshness (update data on demand), and minimal redundant work (don't repeat the same computation).
Each of the four cache layers addresses a different scope of the problem. Understanding each layer's scope, lifetime, and invalidation mechanism is the foundation of Next.js performance optimization. We will examine each layer from the inside out — starting closest to the rendering process and working outward toward the browser.
Layer 1: Request Memoization
Scope: single render tree, in-memory
Lifetime: from render start to render end
Automatic for: fetch calls inside React Server Components
How It Works Internally
Next.js creates a Map<string, Promise<unknown>> at the beginning of every render. As React walks the Server Component tree, each fetch call first checks this Map using the URL plus a hash of the request options as the key:
fetch(url, options) called
↓
hash = serialize(url + options)
↓
Map.has(hash)?
/ \
Yes No
| |
return issue real network request
existing store Promise in Map
Promise return Promise
When the render completes, the Map is discarded. The next incoming request (a different user's page load, or the same user refreshing) starts with an empty Map.
Practical Implication
// Both components call getUser('123') which internally calls fetch('/api/users/123')
// In the same render tree, only ONE real network request is made
// Header.tsx
async function Header() {
const user = await getUser('123') // first call — real request
return <nav>{user.name}</nav>
}
// Sidebar.tsx (rendered in the same tree)
async function Sidebar() {
const user = await getUser('123') // same URL — memoized, no extra request
return <aside>{user.avatar}</aside>
}
This is why Server Components can declare their own data dependencies locally without requiring data to be "lifted" to a common ancestor. The memoization layer handles deduplication transparently. This is one of the core design decisions that makes the Server Component model practical at scale.
Request Memoization applies only to GET fetch requests. POST requests are not memoized (they are inherently non-idempotent). Prisma queries, Axios calls, and other non-fetch data sources are not memoized by default — use unstable_cache to add caching to those.
Layer 2: Data Cache
Scope: cross-request, cross-deployment-instance (persistent)
Lifetime: until explicit invalidation or TTL expiry
Invalidation: revalidateTag(), revalidatePath(), time-based TTL
The Data Cache is the most important and most frequently misunderstood cache layer. It is a persistent key-value store — on Vercel, maintained by the edge infrastructure and shared across server instances; in local development, stored on the filesystem under .next/cache.
Cache Key Construction
A Data Cache entry's key is derived from the fetch URL, HTTP method, and relevant request headers. Two separate components calling fetch with the same URL and options share one Data Cache entry:
// These two calls produce the same cache key and share one Data Cache entry
// In ProductsPage
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] }
})
// In FeaturedProducts widget (different component, potentially different render)
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] }
})
// Second call hits Data Cache — no real network request
The Tag System
Tags connect cache entries to invalidation events. A single fetch can carry multiple tags:
const product = await fetch(`https://api.example.com/products/${id}`, {
next: {
revalidate: 3600,
tags: ['products', `product-${id}`, `category-${categoryId}`],
}
})
revalidateTag('products') invalidates every Data Cache entry carrying the 'products' tag — regardless of URL. This lets you invalidate at different granularities: the entire product catalog, a single product, all products in a category.
Next.js 15's Breaking Change
Next.js 15 changed fetch's default caching behavior from force-cache to no-store. This represents a philosophical shift: the team decided that choosing to cache should be explicit rather than implicit by default.
// Next.js 14 (default: force-cache)
const data = await fetch('/api/data') // cached — may return stale data
// Next.js 15 (default: no-store)
const data = await fetch('/api/data') // always fresh — but always a real request
// Next.js 15, explicit caching:
const data = await fetch('/api/data', {
next: { revalidate: 3600 } // or cache: 'force-cache'
})
If you upgrade from Next.js 14 to 15 and relied on implicit caching, you will need to add explicit cache options to your fetch calls. The Next.js 15 migration guide covers this in detail.
Layer 3: Full Route Cache
Scope: server-side, stored per route
Lifetime: until revalidatePath, redeployment, or cascade from Data Cache invalidation
Applies to: static routes only (routes without dynamic functions)
The Full Route Cache stores two things for each static route: the HTML string and the RSC Payload (React Server Component binary serialization format). When a route is static, Next.js renders it once at build time (or on first visit in on-demand generation), stores the result in the Full Route Cache, and serves subsequent requests directly from the cache without running React rendering at all.
What Makes a Route Dynamic
Any of the following opts a route out of the Full Route Cache:
// 1. Calling cookies() or headers()
import { cookies } from 'next/headers'
const session = cookies().get('session') // route is now dynamic
// 2. Accessing searchParams in a Page component
export default function Page({ searchParams }: { searchParams: { q: string } }) {
// the mere presence of searchParams in the signature makes the route dynamic
}
// 3. Using cache: 'no-store' in any fetch call
const data = await fetch('/api/data', { cache: 'no-store' })
// 4. Explicitly calling noStore()
import { unstable_noStore as noStore } from 'next/cache'
noStore() // opts the current render out of Full Route Cache
Understanding RSC Payload
The RSC Payload is React's serialized representation of the server component tree. It contains:
- A description of the component tree structure (not HTML — React's virtual representation)
- The rendered output of Server Components
- Placeholders for Client Components with their serialized props
When the browser performs client-side navigation (clicking a <Link>), it does not request HTML. It requests the RSC Payload, which React uses to update the component tree in place without a full page reload. The Full Route Cache stores the RSC Payload precisely because it is needed both for the initial page load (alongside HTML) and for subsequent client-side navigation.
Layer 4: Client-side Router Cache
Scope: browser memory, current tab
Lifetime: auto-expires (30 seconds for dynamic routes, 5 minutes for static routes in Next.js 15); cleared on tab close
Stores: RSC Payloads of visited routes
When a user navigates within a Next.js application by clicking <Link> components, the Router Cache is checked before any network request is made:
User clicks link to /products
↓
Router Cache has RSC Payload for /products?
/ \
Yes No
(within expiry window) |
| Request RSC Payload from server
Render immediately Store in Router Cache
(no network request) Render result
Expiry Times in Next.js 15
Next.js 15 refined Router Cache expiry:
- Dynamic routes (using
cookies(),headers(), etc.): 30 seconds - Static routes (routes in the Full Route Cache): 5 minutes
The practical meaning: users browsing static content pages won't make any network requests for navigation within a 5-minute window — navigation is near-instantaneous. However, if you just updated content, users may wait up to 5 minutes before seeing the new version unless you explicitly invalidate the Router Cache.
Invalidating the Router Cache from Server Actions
revalidatePath and revalidateTag in Server Actions invalidate not only the Data Cache and Full Route Cache, but also signal to the client that Router Cache entries for affected routes are stale:
'use server'
export async function updatePost(id: string, formData: FormData) {
await prisma.post.update({ where: { id }, data: parseFormData(formData) })
// Invalidates: Data Cache entries for /posts/[id]
// Full Route Cache for /posts/[id]
// Router Cache entry for /posts/[id] (client-side)
revalidatePath(`/posts/${id}`)
}
The client receives this invalidation signal in the Server Action response and evicts the affected Router Cache entries. The user's next navigation to the route will fetch fresh content.
The Complete Data Flow: All Four Layers Together
Tracing a complete request through all four cache layers clarifies how they interact:
User visits /products
Step 1: Client-side Router Cache check
├── Cache hit (within expiry window) → render immediately, done
└── Cache miss or expired → continue to server
Step 2: Server receives request, checks Full Route Cache
├── Static route with valid Full Route Cache entry → return cached HTML + RSC Payload, done
└── Dynamic route or no Full Route Cache → begin rendering
Step 3: React renders Server Component tree
└── encounters fetch() calls
Step 4: Request Memoization check (per render tree)
├── Same URL already called in this render → return memoized Promise, skip Step 5
└── First call with this URL → continue
Step 5: Data Cache check
├── Valid Data Cache entry exists → return cached data, populate Memoization Map
└── No valid cache entry → issue real network request
store response in Data Cache
populate Memoization Map
Step 6: Render completes
└── Static route: store HTML + RSC Payload in Full Route Cache
Dynamic route: return response directly, no Full Route Cache update
Step 7: Response reaches browser
└── Store RSC Payload in Router Cache for future navigation
Debugging Cache Behavior: NEXT_PRIVATE_DEBUG_CACHE
Next.js exposes a debug environment variable that logs detailed cache hit/miss information:
# In .env.local
NEXT_PRIVATE_DEBUG_CACHE=1
# Or inline with the start command
NEXT_PRIVATE_DEBUG_CACHE=1 next dev
NEXT_PRIVATE_DEBUG_CACHE=1 next start
With this enabled, the terminal outputs cache activity:
[Cache] GET https://api.example.com/products — HIT (Data Cache, age: 1243s)
[Cache] GET https://api.example.com/users/123 — MISS (no entry, fetching...)
[Cache] /products — HIT (Full Route Cache)
[Cache] /dashboard — SKIP (dynamic route, cookies() used)
This is the primary diagnostic tool for "why isn't my data updating?" and "why is this route making a database query every request?" type investigations.
Choosing the Right Cache Strategy
Different page types require different cache combinations:
Fully static marketing pages (About, Landing Page):
// No special configuration needed
// If data fetching is required, be explicit
const data = await fetch('/api/static-content', { cache: 'force-cache' })
Periodically updated blog posts:
const post = await fetch(`/api/posts/${slug}`, {
next: {
revalidate: 86400, // revalidate daily as a fallback
tags: [`post-${slug}`, 'posts'], // but allow on-demand invalidation
}
})
// When post updates: revalidateTag(`post-${slug}`)
Personalized dashboard (must be dynamic):
import { cookies } from 'next/headers'
// cookies() automatically opts into dynamic rendering
const sessionToken = cookies().get('session-token')
// No point caching — data is user-specific
const data = await fetch('/api/user/dashboard', { cache: 'no-store' })
High-traffic e-commerce catalog (frequent reads, infrequent writes):
const getCachedProducts = unstable_cache(
() => prisma.product.findMany({ where: { published: true } }),
['product-catalog'],
{
revalidate: 300, // 5-minute TTL as safety net
tags: ['products'], // immediate invalidation when catalog changes
}
)
// After any product update: revalidateTag('products')
Mental Model: The Four Layers as a Decision Tree
A framework for remembering which layer does what:
| Layer | Location | Stores | Duration | Invalidated By |
|---|---|---|---|---|
| Request Memoization | Server RAM (per render) | fetch Promises |
One render | Automatic (render end) |
| Data Cache | Server persistent storage | Response data | Until explicit invalidation | revalidateTag, revalidatePath, TTL |
| Full Route Cache | Server persistent storage | HTML + RSC Payload | Until invalidation | revalidatePath, redeployment |
| Router Cache | Browser RAM | RSC Payload | 30s–5min | revalidatePath in Server Action, TTL |
The key insight in the Next.js caching architecture is that these layers are composable, not exclusive. A single page request may simultaneously hit the Router Cache (navigation), miss the Full Route Cache (dynamic route), benefit from Data Cache hits for some data sources, and use Request Memoization to deduplicate fetch calls within the render. Understanding the layers means understanding which optimization applies at which moment — and having the diagnostic tools (NEXT_PRIVATE_DEBUG_CACHE) to verify your understanding matches reality.