3.4 — The Next.js caching layers¶
Module 3 · Lesson 4 · 🔴 Advanced · ~45 min
What you'll learn¶
- All five caching layers, what each one holds, and how to invalidate each
- The version matrix — defaults changed meaningfully across Next.js 14 → 15 → 16
- A runtime probe that tells you what your app actually does
- Cache design for a commerce catalog, and the failure modes that leak data between users
⚠️ Verify against your version. Caching defaults are the most version‑sensitive area of Next.js. This lesson gives you the model and a probe to establish ground truth in your own app. Never reason about caching from memory — including this document's.
The five layers¶
┌──────────────────────────────────────────────────────────────────────┐
│ 5. CDN / edge cache HTML + assets, keyed by URL │
│ TTL: s-maxage / SWR Invalidate: purge / surrogate keys │
├──────────────────────────────────────────────────────────────────────┤
│ 4. Full Route Cache (server) Prerendered HTML + RSC payload │
│ TTL: `revalidate` Invalidate: revalidatePath / redeploy │
├──────────────────────────────────────────────────────────────────────┤
│ 3. Data Cache (server) fetch() results, `use cache` values │
│ TTL: revalidate / cacheLife Invalidate: revalidateTag / cacheTag │
├──────────────────────────────────────────────────────────────────────┤
│ 2. Request Memoization Dedupes identical calls WITHIN one │
│ Lifetime: one render pass render. Nothing to invalidate. │
├──────────────────────────────────────────────────────────────────────┤
│ 1. Router Cache (client) RSC payloads for visited routes │
│ Lifetime: in-memory, short Invalidate: router.refresh(), nav │
└──────────────────────────────────────────────────────────────────────┘
A request travels 5 → 4 → 3, and a client navigation checks 1 first. Understanding which layer served a response is the core debugging skill for TTFB problems.
Layer 2 — Request Memoization¶
Deduplicates identical calls within a single render pass. If five components each call
getProduct('abc'), the fetch happens once.
fetch gets this automatically (keyed on URL + options). For anything else — a database query, a
gRPC call, an SDK — wrap it in React's cache():
// lib/data/product.ts
import { cache } from 'react';
import 'server-only';
// Memoized per render pass. Call it from the page, the metadata function,
// and three components — it executes once.
export const getProduct = cache(async (slug: string): Promise<Product | null> => {
const row = await db.product.findUnique({ where: { slug } });
return row ? toProduct(row) : null;
});
// Both of these run during the same render — one DB query total
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
return { title: product?.name };
}
export default async function Page({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // memoized hit
return /* … */;
}
This is free and you should do it for every data accessor. It's the cheapest fix for the "why are we hitting the database six times per render" problem.
Caveats: memoization is per‑request, so it doesn't help across users. Arguments must be stable —
cache() keys on argument identity, so passing a fresh object literal each time defeats it.
// ❌ New object every call → never a memo hit
const getProducts = cache(async (opts: { ids: string[] }) => { /* … */ });
getProducts({ ids: ['a', 'b'] });
// ✅ Primitive arguments
const getProducts = cache(async (idsCsv: string) => { /* … */ });
getProducts('a,b');
Layer 3 — The Data Cache¶
Persists fetched data across requests and users, and (on most deployments) across deploys. This is where catalog data should live.
// Explicit caching with a tag — the pattern to standardize on
async function getProduct(id: string) {
const res = await fetch(`${API}/products/${id}`, {
next: {
revalidate: 3600, // refresh at most hourly
tags: [`product-${id}`, 'products'], // targeted invalidation
},
});
if (!res.ok) throw new Error(`Product fetch failed: ${res.status}`);
return res.json();
}
// Explicitly uncached — real-time data
async function getInventory(id: string) {
const res = await fetch(`${API}/inventory/${id}`, { cache: 'no-store' });
return res.json();
}
For non‑fetch data sources, use unstable_cache (or use cache on newer versions):
import { unstable_cache } from 'next/cache';
export const getCategoryTree = unstable_cache(
async (marketId: string) => db.category.findMany({ where: { marketId } }),
['category-tree'], // key prefix; arguments are appended
{ revalidate: 3600, tags: ['categories'] },
);
On Next.js 15.x (experimental) and 16 (cacheComponents), the use cache directive is the
successor and is much nicer to read:
// Requires the relevant experimental flag / Next.js 16 Cache Components.
// Check your version's docs before adopting.
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
async function getProduct(id: string) {
'use cache';
cacheLife('hours'); // named profile: seconds/minutes/hours/days/weeks/max
cacheTag(`product-${id}`);
return db.product.findUnique({ where: { id } });
}
Invalidation¶
// app/api/webhooks/catalog/route.ts
// Called by the PIM when a product changes.
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
export async function POST(req: NextRequest) {
const signature = req.headers.get('x-webhook-signature') ?? '';
const body = await req.text();
if (!verifySignature(body, signature)) {
return new NextResponse('Invalid signature', { status: 401 });
}
const { type, productId, categoryId } = JSON.parse(body);
switch (type) {
case 'product.updated':
case 'product.price_changed':
revalidateTag(`product-${productId}`);
// The product also appears on category pages
if (categoryId) revalidateTag(`category-${categoryId}`);
break;
case 'category.updated':
revalidateTag(`category-${categoryId}`);
break;
case 'catalog.bulk_import':
// Deliberately coarse — a bulk import touches everything
revalidateTag('products');
break;
}
return NextResponse.json({ revalidated: true, now: Date.now() });
}
Tag design is the skill here. Tag hierarchy for a commerce catalog:
product-{id} one SKU changed
variant-{id} one variant (size/color) changed
category-{id} category membership or ordering changed
brand-{id} brand-level change
market-{code} market-wide pricing change
products nuclear option — bulk import only
Rules:
- Tag at write time with everything that could invalidate it. Cheap to add, impossible to
retrofit.
- Never use one global tag for everything. revalidateTag('all') on every price change means
you have no cache.
- Price changes are the high‑frequency event. If prices change every few minutes for thousands
of SKUs, don't cache price in the Data Cache at all — stream it from a dynamic hole
(3.6).
Layer 4 — The Full Route Cache¶
Stores the rendered HTML and RSC payload for statically‑rendered routes. Populated at build time
(generateStaticParams) or on first request (dynamicParams), refreshed by revalidate or
revalidatePath.
// On-demand: purge a specific rendered page
revalidatePath('/p/wool-overshirt-navy');
revalidatePath('/c/[slug]', 'page'); // all category pages (the dynamic segment form)
revalidatePath('/', 'layout'); // everything under the root layout — expensive
A route is only in the Full Route Cache if it renders statically. Any dynamic API
(cookies(), headers(), uncached fetch) opts the route out —
see 3.1.
Self‑hosted note: by default the Full Route Cache is written to the filesystem of each
instance. With 60–400 Kubernetes pods, that's 400 independent caches: your hit ratio is
1/N, and revalidateTag from one pod doesn't reach the others. You need a shared cache handler:
// cache-handler.mjs — shared ISR cache backed by Redis
// next.config.ts: { cacheHandler: require.resolve('./cache-handler.mjs'), cacheMaxMemorySize: 0 }
import { createClient } from 'redis';
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
export default class RedisCacheHandler {
async get(key) {
const raw = await client.get(`next:${key}`);
return raw ? JSON.parse(raw) : null;
}
async set(key, data, ctx) {
const ttl = ctx?.revalidate ? ctx.revalidate * 2 : 60 * 60;
await client.set(`next:${key}`, JSON.stringify({ value: data, lastModified: Date.now() }), {
EX: ttl,
});
// Maintain tag → key sets so revalidateTag can fan out across all pods
for (const tag of ctx?.tags ?? []) {
await client.sAdd(`next:tag:${tag}`, `next:${key}`);
}
}
async revalidateTag(tags) {
for (const tag of [tags].flat()) {
const keys = await client.sMembers(`next:tag:${tag}`);
if (keys.length) await client.del(keys);
await client.del(`next:tag:${tag}`);
}
}
}
This single change took Aurora's self‑hosted ISR hit ratio from 11% to 96%. If you self‑host Next.js on more than one instance and haven't configured a shared cache handler, this is probably your biggest TTFB win and it's half a day of work.
Layer 1 — The client Router Cache¶
Holds RSC payloads for routes the user has visited or prefetched, in memory, for the duration of the session. It makes back/forward navigation instant — and it's the reason a user can see stale data after an update.
// next.config.ts — control how long segments stay fresh (Next.js 15+)
const config: NextConfig = {
experimental: {
staleTimes: {
dynamic: 30, // seconds a dynamic page segment is reused without a refetch
static: 180, // seconds a static page segment is reused
},
},
};
Defaults here changed in Next.js 15 (page segments default to 0 staleness, so navigating back
refetches). Raising dynamic makes navigation feel instant but risks showing stale prices — a
real commerce trade‑off, not a free win.
Forcing a refresh after a mutation:
'use client';
import { useRouter } from 'next/navigation';
export function AddToCartButton({ productId }: { productId: string }) {
const router = useRouter();
return (
<button
onClick={async () => {
await addToCart(productId);
router.refresh(); // refetch the current route's RSC payload; keeps client state
}}
>
Add to bag
</button>
);
}
With Server Actions, revalidatePath/revalidateTag inside the action handles this for you —
prefer that (5.6).
The version matrix¶
| Behavior | Next.js 14 | Next.js 15 | Next.js 16 |
|---|---|---|---|
fetch() default |
Cached (force-cache) |
Uncached (no-store) |
Uncached; use cache is the explicit model |
| GET Route Handlers | Cached by default | Uncached by default | Uncached |
| Client Router Cache (page segments) | 30 s dynamic / 5 min static | 0 by default, configurable via staleTimes |
Configurable |
use cache directive |
— | Experimental (dynamicIO / useCache) |
Cache Components (cacheComponents) |
| PPR | Experimental | Experimental (ppr) |
Available via Cache Components model |
| Default bundler | Webpack | Webpack (Turbopack opt‑in) | Turbopack default |
| Middleware file | middleware.ts |
middleware.ts |
proxy.ts (renamed) |
The upgrade that bites everyone: 14 → 15. Fetches that were silently cached become uncached,
so TTFB and origin load jump on upgrade day with no code change. Before upgrading, audit every
fetch in server code and make its intent explicit:
# Find fetches with no explicit cache intent — each is a 14→15 behavior change
rg -n --multiline 'fetch\((?:[^)]|\n)*?\)' app lib \
| rg -v "cache:|next:\s*\{" | head -50
Then make every one explicit. Explicit is correct in every version and immune to the next default change:
await fetch(url, { cache: 'force-cache', next: { revalidate: 3600, tags: ['x'] } }); // cached
await fetch(url, { cache: 'no-store' }); // not cached
The probe: find out what your app actually does¶
Don't trust documentation, including this page. Measure.
// app/debug/cache-probe/page.tsx
// Protect behind an env check or auth. Remove before it becomes a permanent fixture.
export const dynamic = 'force-dynamic';
async function probe(label: string, fn: () => Promise<unknown>) {
const t0 = performance.now();
await fn();
return { label, ms: Math.round(performance.now() - t0) };
}
export default async function CacheProbe() {
if (process.env.NODE_ENV === 'production' && !process.env.ENABLE_CACHE_PROBE) {
return <p>Disabled.</p>;
}
const results = await Promise.all([
probe('fetch default', () => fetch(`${API}/health`).then((r) => r.json())),
probe('fetch force-cache', () => fetch(`${API}/health`, { cache: 'force-cache' }).then((r) => r.json())),
probe('fetch no-store', () => fetch(`${API}/health`, { cache: 'no-store' }).then((r) => r.json())),
]);
return (
<table>
<tbody>
{results.map((r) => (
<tr key={r.label}><td>{r.label}</td><td>{r.ms} ms</td></tr>
))}
</tbody>
</table>
);
}
Reload several times. A call that stays at 0–2 ms is cached; one that stays at network latency isn't. More precisely, use the built‑in cache logging:
// next.config.ts
const config: NextConfig = {
logging: {
fetches: { fullUrl: true, hmrRefreshes: true },
},
};
GET /p/wool-overshirt-navy 200 in 45ms
│ GET https://api.aurora.com/products/1234 200 in 2ms (cache hit)
│ GET https://api.aurora.com/inventory/1234 200 in 138ms (cache skip)
│ │ Cache missed reason: (cache: no-store)
That last line is the answer to most "why is this slow" questions. Turn it on in development and read it after any caching change.
In production, verify with headers:
curl -sI https://www.auroramarket.com/p/wool-overshirt-navy \
| grep -iE 'x-nextjs-cache|x-vercel-cache|age|cache-control|x-cache'
Cache design for Aurora's catalog¶
| Data | Layer | TTL | Invalidation | Why |
|---|---|---|---|---|
| Product content (name, copy, images, specs) | Data Cache | 24 h | product-{id} on PIM webhook |
Changes rarely; big payload |
| Category tree | Data Cache | 1 h | categories on merch publish |
Small, shared by all pages |
| Base price | Data Cache | 5 min | product-{id} on price webhook |
Changes a few times/day |
| Promotional price / segment price | None | — | — | Per‑user; must be a dynamic hole |
| Inventory | None (or 30 s) | — | — | Oversell risk; correctness beats speed |
| Reviews summary (count, avg) | Data Cache | 1 h | product-{id} on new review |
Tolerant of staleness |
| CMS blocks | Data Cache | 10 min | cms-{id} on publish |
Merchandisers expect fast publish |
| Search results | CDN, popular queries only | 5 min | Time‑based | Long tail is uncacheable |
| Recommendations | Data Cache keyed by product | 30 min | Time‑based | Personalization happens client‑side on top |
| Cart | Never | — | — | Per‑user, mutable |
| User/session | Never | — | — | Per‑user |
The rule that prevents the catastrophic bug¶
Never cache a response that contains data derived from
cookies(), a session, or a user ID — at any layer. The failure mode is one customer seeing another customer's cart, address, or order history. It has happened to real retailers and it is a reportable data breach, not a bug.
Enforce it mechanically, not by review:
// lib/data/guard.ts
import 'server-only';
/**
* Wrap any function whose result is cached. Throws in development/CI if the
* result contains fields that must never be shared between users.
*/
const FORBIDDEN_KEYS = ['email', 'sessionId', 'userId', 'customerId', 'addressLine1', 'phone'];
export function assertNotPersonalized<T>(value: T, context: string): T {
if (process.env.NODE_ENV === 'production') return value;
const json = JSON.stringify(value);
for (const key of FORBIDDEN_KEYS) {
if (json.includes(`"${key}"`)) {
throw new Error(
`[cache-guard] ${context} returned a personalized field "${key}" but is cached. ` +
`Move it to a dynamic hole.`,
);
}
}
return value;
}
Pair it with an integration test that requests a cacheable page as user A, then as user B, and asserts no A‑specific string appears in B's HTML. Run it in CI on every PR that touches caching.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Assuming Next.js 14 caching defaults on 15+ | Silent TTFB regression on upgrade |
| Self‑hosting on N pods without a shared cache handler | Hit ratio ≈ 1/N; revalidateTag doesn't propagate |
| One global cache tag | Every write invalidates everything |
| Caching anything derived from cookies | 🚨 Data leak between users |
Not using cache() for DB accessors |
Duplicate queries per render |
revalidatePath('/', 'layout') on every write |
Purges the whole site |
| Unsigned revalidation webhooks | Anyone can DoS your origin by purging in a loop |
| Caching inventory | Overselling |
| Trusting docs over measurement | The defaults changed; your app is the ground truth |
Lab 3.4 — Map your cache¶
- Turn on
logging.fetchesand load your three main page types in dev. Record every fetch: URL, duration, cache hit/skip, and the skip reason. - Build the table from "Cache design for Aurora's catalog" for your data, with a decision and a reason for each row.
- If you self‑host: check whether a shared
cacheHandleris configured. If not, measure your ISR hit ratio (x-nextjs-cacheheader across many requests) — expect it to be terrible. - Audit tags:
rg -n "revalidateTag|revalidatePath|tags:\s*\[" app lib. Is there a hierarchy, or one global tag? - Write the cross‑user leak test. Run it in CI.
- Verify production cache headers on every route type.
Checklist¶
- Every
fetchin server code has explicit cache intent - All data accessors wrapped in
cache() - Tag hierarchy designed; no single global tag
- Revalidation webhooks signature‑verified and rate‑limited
- Shared cache handler configured if self‑hosting multi‑instance
- Nothing personalized is cached, enforced by a guard + a CI test
- Cache behavior verified by measurement in dev and by headers in production
- Version‑specific defaults confirmed for your exact Next.js minor version
Next: 3.5 ISR at catalog scale