Chapter 13

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:

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:

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.

Rate this chapter
4.7  / 5  (22 ratings)

๐Ÿ’ฌ Comments