open atlas
↑ Back to track
Next.js, zero to senior NEXT · 06 · 03

ISR and CDN layers: stale-while-revalidate at origin, per-pod caches, and purge ordering

revalidate serves stale, regenerates in the background and swaps atomically — per pod. Past one pod the default disk cache diverges: you need a shared cacheHandler and pub/sub fan-out for revalidatePath. On top, the CDN layer orders as regenerate first, purge second.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Friday, 16:40: the CMS publishes a price change, the webhook calls revalidatePath('/pricing'), the admin sees the new number, everyone goes home. By 17:10 support has nine tickets with the same screenshot pair: the pricing page shows the old price — refresh — the new price — refresh — the old one again. The site self-hosts on three pods behind a round-robin ALB. revalidatePath ran on the one pod that happened to receive the webhook; the other two kept serving their own disk caches, and the load balancer deals users a different pod per request. Worse: the page is on-demand-only — no time-based revalidate — so pods A and C will not heal themselves ever; they will serve the stale price until the next deploy replaces their filesystems. Monday opens with a legal escalation, because the advertised price and the charged price spent a weekend disagreeing — at a rate of exactly two out of every three requests.

ISR is stale-while-revalidate at the origin

revalidate: 60 does not mean “regenerate every 60 seconds” and it does not mean “users wait for fresh HTML”. It is stale-while-revalidate implemented at the origin: every cached entry carries a timestamp, and the first request after the window expires is served the stale page immediately — single-digit milliseconds from cache — while exactly one regeneration kicks off in the background. When the render finishes, the new entry swaps atomically: no request ever sees a half-written page. If the regeneration throws, the swap simply does not happen — the last good page keeps serving and the next request after the window retries. The serving math is what makes ISR attractive at all: a cache hit is 1–5 ms of IO, a full regeneration is whatever your page render costs — typically 100–800 ms of data fetching and RSC work — and under ISR nobody waits for it except the regeneration itself.

// app/pricing/page.tsx — time-based: at most one regen per 60 s window
export const revalidate = 60;

// or per-fetch, with tags for on-demand invalidation:
const res = await fetch('https://cms.example.com/prices', {
  next: { revalidate: 60, tags: ['pricing'] },
});

// app/api/cms-webhook/route.ts — on-demand: invalidate now, not on a timer
import { revalidateTag } from 'next/cache';
export async function POST() {
  revalidateTag('pricing'); // marks entries stale — IN THIS PROCESS ONLY
  return Response.json({ ok: true });
}

More than one pod: the cache must be shared

Everything above is true per process. The default ISR store is a filesystem cache under .next/cache plus an in-memory LRU — pod-local on both counts. One pod, one truth. Three pods, three truths: each regenerates on its own schedule, so time-based pages drift a window apart — usually tolerable. What is not tolerable is on-demand revalidation: revalidatePath and revalidateTag mutate the local store of whichever pod ran them. The webhook lands on pod B, pod B regenerates, pods A and C never hear about it — and a round-robin balancer turns that into the flip-flop from the hook, fresh and stale alternating per request. With no time-based fallback the stale pods never converge at all.

Two production-grade fixes. The structural one: replace the store with a shared cacheHandlernext.config.js accepts cacheHandler: require.resolve('./cache-handler.mjs') plus cacheMaxMemorySize: 0 to disable the per-pod LRU, and your handler implements get/set/revalidateTag against Redis or another shared store. Now there is one truth again; the price is ~1–3 ms of Redis RTT on every ISR hit instead of sub-millisecond local disk — the cost of consistency. The alternative keeps local caches but fans out the invalidation: the webhook publishes to Redis pub/sub, every pod subscribes and calls revalidateTag locally. Fan-out keeps hits at local-disk speed but reintroduces a distributed-systems problem — a pod that was restarting during the publish missed it, and nothing reconciles. The shared store gives you correctness by construction; fan-out gives you speed at the cost of a reconciliation gap you must monitor. Shared store is the boring, correct default; fan-out is the optimization you adopt with monitoring.

Quiz

Three pods, default ISR cache, a CMS webhook calls revalidatePath on whichever pod receives it. The page has no time-based revalidate. What do users actually see?

The CDN layer on top — and purge ordering

In front of the pods sits a CDN, and ISR pages advertise themselves to it: a route with revalidate: 300 is served with Cache-Control: s-maxage=300, stale-while-revalidate — the CDN may cache it for five minutes and serve stale while it refetches. A healthy content site runs 80–98% CDN hit ratio, which means your pods see only the trickle of misses; the CDN is doing the same stale-while-revalidate dance one layer up, against your origin instead of against your render function. Two caches, same protocol, stacked.

Stacked caches have an ordering theorem, and violating it is the second classic incident: regenerate the origin first, purge the CDN second. Run it in the wrong order and walk the timeline: purge completes, the CDN is empty, every request for the page is now a miss hitting your origin — a thundering herd of misses — and the origin, which has not regenerated yet, dutifully serves the old page under SWR semantics. The CDN caches that old page again, stamped with a fresh s-maxage=300, and your “urgent fix” is now pinned for another full window — you purged, and the stale content came straight back. The correct sequence: trigger revalidateTag/revalidatePath on the origin (reaching every pod, per the previous section), confirm the origin serves fresh, then purge the CDN so the resulting misses pull the new page. The herd still happens — that is what purges cost — but it pulls the right bytes.

Why this works

Why do both layers converge on stale-while-revalidate instead of serve-fresh-or-wait? Because the alternative couples user latency to regeneration cost. If expiry meant the next user waits for a 600 ms render — or for an origin fetch — every cache expiry becomes a visible latency spike, and a popular page expiring becomes a stampede of concurrent regenerations. SWR decouples the two: readers always get cache-speed responses, writers regenerate exactly once in the background, and the system trades a bounded window of staleness for flat latency. The whole stack — browser, CDN, ISR — is the same bet made three times.

Quiz

A wrong price is live. Ops purges the CDN first, then triggers on-demand revalidation at the origin. What happens in the gap between the two?

Recall before you leave
  1. 01
    Describe the exact ISR serving sequence at expiry, and what happens when regeneration fails. Why is this design called SWR at the origin?
  2. 02
    Why does revalidatePath break on three self-hosted pods, what are the two fixes, and what does each cost?
Recap

ISR in production is stale-while-revalidate implemented at the origin: entries carry timestamps, the first request after expiry gets the stale page in 1-5 ms while exactly one regeneration runs in the background, the finished render swaps in atomically, and a failed render changes nothing — last-good keeps serving and the next window retries. Readers never pay the 100-800 ms regeneration cost; that is the entire point. All of it is per-process, and that is where self-hosting bites: the default store is .next/cache on pod-local disk plus a pod-local LRU, so three pods are three diverging truths. Time-based pages drift a window apart and self-heal; on-demand revalidatePath and revalidateTag mutate only the pod that runs them, so a CMS webhook landing on pod B leaves A and C permanently stale on on-demand-only pages — and a round-robin balancer converts that into the per-request price flip-flop. The structural fix is a shared cacheHandler wired in next.config with cacheMaxMemorySize zero, backed by Redis, paying 1-3 ms per hit for a single truth; the optimization is pub/sub fan-out of invalidations, which keeps disk-speed hits but can silently miss a restarting pod. Above the pods, the CDN runs the same SWR protocol via s-maxage and stale-while-revalidate headers at 80-98% hit ratios — and stacked caches obey one ordering theorem: regenerate the origin first, confirm fresh, purge the CDN second. Purge-first sends a herd of misses into an origin still serving stale, re-caching the old page under a fresh s-maxage for another full window. Browser, CDN, ISR — the same bet, made three times, and it only pays if invalidation reaches every layer and every pod. Now when you see a CMS webhook that calls revalidatePath, your first question is: how many pods is this running on, and does the invalidation reach all of them?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.