Back/Module P-6 Caching in Practice — The Four-Layer Model and the `use cache` Directive
Module P-6·29 min read

The four cache layers, the old fetch()-based model vs the new use cache directive, cacheLife() TTL profiles, cacheTag()/updateTag() for on-demand invalidation, and staleTimes tuning.

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 cache directive 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

LayerWhereScopeDurationInvalidation
Request MemoizationReact renderSingle requestEnds with requestAutomatic (per request)
Data CacheNext.js serverCross-requestConfigurable / indefiniterevalidateTag, revalidatePath, time-based
Full Route CacheCDN / Next.jsCross-requestIndefinite (static)revalidatePath, revalidateTag, redeploy
Router CacheBrowserCurrent session30s–5minrouter.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.

tsx
// Both components fetch the same URL — only one network request fires async function ComponentA() { const user = await fetch('/api/user/123').then(r => r.json()); return <div>{user.name}</div>; } async function ComponentB() { const user = await fetch('/api/user/123').then(r => r.json()); // ← memoised return <div>{user.email}</div>; }

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

ts
// Cached indefinitely const data = await fetch('/api/products', { cache: 'force-cache' }); // Not cached (dynamic) const data = await fetch('/api/inventory', { cache: 'no-store' }); // Cached, revalidate after 60 seconds const data = await fetch('/api/posts', { next: { revalidate: 60 } }); // Cached with tag for on-demand invalidation const data = await fetch('/api/products', { next: { tags: ['products'] } });

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:

ts
// lib/queries.ts async function getProducts() { 'use cache'; // ← this function's result is now cached // Can be a database query, not just fetch() return db.products.findMany({ where: { published: true } }); } async function getProductBySlug(slug: string) { 'use cache'; return db.products.findUnique({ where: { slug } }); }

use cache can also be applied at the file level (all exports cached) or at the component level:

tsx
// File-level: all exports in this file are cached 'use cache'; export async function getAllPosts() { return db.posts.findMany({ where: { published: true } }); } export async function getCategories() { return db.categories.findMany(); }
tsx
// Component-level: this Server Component's output is cached async function ProductList() { 'use cache'; const products = await db.products.findMany(); return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>; }

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:

ts
import { cacheLife } from 'next/cache'; async function getProducts() { 'use cache'; cacheLife('hours'); // ← built-in profile: stale after 1 hour, max 1 day return db.products.findMany(); }

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:

ts
cacheLife({ stale: 60, // serve stale content for up to 60 seconds revalidate: 300, // revalidate in the background every 5 minutes expire: 3600, // hard expiry: force fresh fetch after 1 hour });

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:

ts
// next.config.ts const config: NextConfig = { experimental: { cacheLife: { 'product-listing': { stale: 60, revalidate: 300, expire: 3600, }, 'user-feed': { stale: 10, revalidate: 60, expire: 600, }, }, }, };
ts
cacheLife('product-listing');

cacheTag() and revalidateTag() — On-Demand Invalidation

ts
import { cacheTag } from 'next/cache'; async function getProducts() { 'use cache'; cacheTag('products'); // ← tag this cache entry cacheTag('products-list'); // ← can have multiple tags return db.products.findMany(); } async function getProduct(id: string) { 'use cache'; cacheTag('products'); cacheTag(`product-${id}`); // ← entity-specific tag return db.products.findUnique({ where: { id } }); }

When a product is updated, invalidate only the affected entries:

ts
// Server Action: update a product export async function updateProduct(id: string, data: ProductUpdateData) { 'use server'; await db.products.update({ where: { id }, data }); revalidateTag(`product-${id}`); // invalidates this specific product's cache revalidateTag('products'); // invalidates the product listing cache }

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(), or connection() anywhere in the render chain
  • Using fetch() with cache: 'no-store'
  • Using use cache without a long-enough cacheLife (if the function itself is dynamic, the route is dynamic)
  • Accessing searchParams in 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:

ts
// Invalidates the Full Route Cache for /blog and all pages under it revalidatePath('/blog', 'layout'); // Invalidates just the /blog page revalidatePath('/blog', 'page'); // Invalidates all pages tagged with 'posts' revalidateTag('posts');

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:

ts
// Option 1: router.refresh() after mutation — clears Router Cache for current route const router = useRouter(); await updateProduct(id, data); router.refresh(); // Option 2: redirect() in the Server Action — route change clears Router Cache for the new route redirect(`/products/${id}`); // ← navigating to the route fetches fresh RSC payload // Option 3: Configure Router Cache staleness (next.config.ts) const config: NextConfig = { experimental: { staleTimes: { dynamic: 0, // don't cache dynamic route payloads at all static: 180, // cache static route payloads for 3 minutes }, }, };

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:

  1. 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.
  2. 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 cache or fetch()+force-cache), return it without hitting the origin. If not, fetch from origin and store.
  3. Response sent. HTML and RSC payload delivered to the browser.

  4. Browser caches. The RSC payload is stored in the Router Cache for the configured staleTimes duration. Subsequent navigations to /products/shoes use 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_cacheuse cache
CachesFunction return valuesFunction return values + Server Component output
SetupWrap functionAdd directive
Works withAny async functionAny async function + Server Components
Cache keyManual arrayAutomatically derived from closure
Profilesrevalidate + tagscacheLife() + cacheTag()
StatusStable, maintainedExperimental (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:

Database queries                 → use cache + cacheTag(entity) + cacheLife(appropriate TTL)
Static content (docs, marketing) → use cache + cacheLife('weeks')  [or 'max']
User-specific content            → no cache (always dynamic)
Product listings                 → use cache + cacheLife('hours') + cacheTag('products')
Real-time data (inventory, price)→ no cache, ISR with short revalidate, or client-side fetch
Aggregates (counts, totals)      → unstable_cache with moderate TTL + revalidateTag on mutation
ts
// lib/queries.ts // Product detail — cache with tag for on-demand invalidation export async function getProduct(id: string) { 'use cache'; cacheTag('products', `product-${id}`); cacheLife('hours'); return db.products.findUnique({ where: { id } }); } // Product listing — similar TTL, coarser tag export async function getProductsByCategory(category: string) { 'use cache'; cacheTag('products', `category-${category}`); cacheLife('hours'); return db.products.findMany({ where: { category } }); } // User profile — user-specific, moderate TTL export async function getUserProfile(userId: string) { 'use cache'; cacheTag(`user-${userId}`); cacheLife('minutes'); return db.users.findUnique({ where: { id: userId } }); } // User's order history — no caching (sensitive, must be fresh) export async function getUserOrders(userId: string) { return db.orders.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, }); }

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.

Discussion