What you'll learn: By the end of this lesson you will be able to state what stale-while-revalidate allows under RFC 5861, trace a page through the fresh, stale and regenerating states of Next.js incremental static regeneration, choose between time-based revalidation,
revalidateTag,revalidatePathand a CDN purge, explain why an on-demand call does not reach a CDN's copy, and reason about the worst-case age of a page that passes through several caches.
A newsstand vendor keeps today's paper on the rack. When a new edition arrives, you get the rack copy now while a runner fetches the new one, and the next customer gets the new edition. That is stale-while-revalidate. If the publisher finds a printing error, the publisher phones the vendor: "pull the old one". That call is on-demand revalidation. The trouble starts when a second stand holds copies the publisher never phoned.
The running example is the product page of an online store, with 20,000 products (an illustrative size). A wrong price is worse than a slow page. Rendering Strategies introduced ISR, and HTTP Caching for the Front End covered the stale-while-revalidate header. This lesson adds the precise semantics, the on-demand tools, and caches in series.
The core idea
Every cached page has two clocks: how old it is, and whether anything has told it to expire. Time-based revalidation uses the first. On-demand revalidation adds the second. Both serve the old copy at once and refresh in the background.
- Stale-while-revalidate trades freshness for latency, and bounds the trade. A stale copy may be served for a limited window, and a request triggers the refresh.
- Revalidation is lazy. Marking something stale renders nothing. The next request does the work.
- Each cache layer has its own copy and clocks. An invalidation travels only as far as the layer you call it on.
Stale-while-revalidate, exactly
RFC 5861 (an Informational document from May 2010, not a Standards Track specification) says that when stale-while-revalidate is present, caches may serve the response after it becomes stale, up to the stated number of seconds. A cache that does so should try to revalidate without blocking. If the window passes without revalidation, it should not keep serving the stale copy "absent other information", and the next request is handled normally.
The words are "may" and "should", so a cache that ignores the extension still conforms, which is why support varies. And asynchronous validation only happens when a request arrives after the response became stale but before the window ends: a quiet page is refreshed by its first visitor, who gets the old copy.
Figure 1. The life of a cache entry
How to read this: "Stale" is a normal, served state, not an error. The only state where a visitor waits for a render is "Blocked".
Time-based revalidation in Next.js
Illustration 1: the minimal example. Behaviour below is from the Next.js App Router documentation as read on 2026-11-08 (the site tracks 16.4 as the latest release; the pages read were last updated between June and October 2026). That guide covers ISR without the newer Cache Components option, which has its own guide, so check which model your app uses.
// app/products/[id]/page.tsx (adapted from the Next.js docs example; not run here)
export const revalidate = 3600; // seconds
export async function generateStaticParams() {
const products: { id: string }[] = await fetch("https://api.example.com/products").then((r) => r.json());
return products.map((p) => ({ id: String(p.id) }));
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await fetch(`https://api.example.com/products/${id}`).then((r) => r.json());
return (
<main>
<h1>{product.name}</h1>
<p>{product.price}</p>
</main>
);
}
Pages are prerendered at build. After the time passes, the next request still returns the cached page, a new version generates in the background, and once it succeeds the next request gets it. If an error is thrown while revalidating, the last good page keeps being served and the next request retries. Details that matter for design:
- Next.js regenerates both the HTML and the RSC payload (the data the client router uses for in-app navigation) from one render and stores them together. Mixed renders can show mismatched content during client navigation.
- The
x-nextjs-cacheresponse header reportsHIT,STALE,MISSorREVALIDATED.
For CDNs, the Next.js CDN guide says ISR pages carry s-maxage={revalidate}, stale-while-revalidate={expire - revalidate} and that the default expire is one year. With revalidate = 3600 and a 365-day year that is s-maxage=3600, stale-while-revalidate=31532400. So revalidate controls when refreshing starts, not when the stale copy stops being served.
On-demand revalidation
When you know when data changed, do not wait for a timer. Two functions, callable from Server Functions and Route Handlers but not Client Components or Proxy, handle it.
revalidatePath(path, type?)invalidates a page, or withtype: "layout"a layout and everything beneath it. A pattern like/products/[id]needstype. With rewrites, pass the destination route, not the visible URL.revalidateTag(tag, profile)marks tagged data stale across every page that used it. Tags are case-sensitive and up to 256 characters (a longer tag is never assigned). Attach them withfetch(url, { next: { tags: [...] } })orcacheTaginside a"use cache"function.
The second argument sets how long stale may be served. The docs recommend "max", a one-year window, so requests always get the stale copy while regeneration runs. { expire: 0 } means stale is never served and the next request blocks. The one-argument form is deprecated and behaves like { expire: 0 }.
Illustration 2: the realistic example. The product page tags its fetch, and a webhook from the admin tool calls a Route Handler. The shared-secret check is this design's assumption, not a Next.js feature.
// app/products/[id]/page.tsx (excerpt)
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: [`product-${id}`, "products"] },
});
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
if (request.headers.get("x-webhook-secret") !== process.env.REVALIDATE_SECRET) {
return Response.json({ revalidated: false }, { status: 401 });
}
const { productId } = await request.json();
revalidateTag(`product-${productId}`, "max");
return Response.json({ revalidated: true });
}
Two documented caveats shape the design. Revalidation is triggered by a request, not by the call, so a webhook for 500 products renders no pages itself. And revalidatePath("/blog") refreshes that page but not a /dashboard page reading the same data under the same tag. Use a tag for shared data, a path for one page or layout.
Figure 2. Where each layer's copy lives
How to read this: the webhook arrow ends at the server cache. Nothing points at the CDN, so its copy keeps its own clock until something purges it.
Layers: why the CDN still serves the old page
The Next.js CDN guide says CDN-level caching alone does not support on-demand revalidation. Those calls invalidate the server cache, "but the CDN will continue serving its cached copy until the s-maxage TTL expires". The recommended pattern is to call the revalidation function, then your CDN's purge API for the affected keys, including both the HTML and RSC variants.
Illustration 3: the failure case. A price is edited at 10:00 and the webhook marks the origin entry stale. But shoppers rarely reach the origin: the CDN answers from its own copy, and the "fix" appears to have done nothing. The origin is correct, the edge is not, and x-nextjs-cache says nothing about the edge.
Figure 3. A change reaching the origin and the CDN
How to read this: two separate calls are needed, one per layer. The first visitor after the change still sees the old price, which is the price of never making anyone wait.
CDN behaviour is vendor-specific. As documented at the time of writing:
- Vercel's CDN purges by cache tag. Invalidating marks content stale, so the next request gets the stale copy while a refresh runs. Deleting makes the next request wait for the origin, and Vercel warns this can cause a cache stampede. On Next.js 16 and later,
revalidateTag(tag, "max")is the invalidate form. Its docs warn against settingVercel-CDN-Cache-Controlon routes ISR already caches. - Cloudflare purges by URL, tag, hostname, prefix or everything on every plan. Its cache-control page says
stale-while-revalidateis not supported with the Workers Cache API and that usings-maxagedisables it, becauses-maxageimpliesproxy-revalidateunder RFC 9111. A header copied from a Next.js response may not give stale-while-revalidate there. Test it. - A self-hosted cluster has the same shape inside the origin: the default file-system cache is per instance, so an on-demand call invalidates only the instance that received it. A shared cache handler fixes that.
One more trap is the cache key. Next.js sets Vary on its internal request headers and adds an _rsc parameter so a CDN does not serve HTML to an RSC request. A CDN that strips query parameters from the key breaks that.
How old can a page be? Layers in series
This is an upper-bound argument, not a measurement. Suppose the origin has revalidate = R, the CDN honours s-maxage = C with stale-while-revalidate, and no on-demand call is made. After a change, the CDN's copy can stay fresh for up to C. The first request after that gets the stale copy and triggers a refresh. If that refresh reaches an origin entry that is itself stale, the origin answers with its stale copy, possibly with a full-length s-maxage, and the CDN stores it as fresh again. The documentation read here does not say what header the origin sends on a stale reply, so the bound assumes the full value.
| Setup (illustrative, R = C = 60 s) | Reasoned worst-case age after a change |
|---|---|
| Time-based only | Up to the sum of the windows, about 120 s, plus the stale serve that triggers each refresh |
revalidateTag, no CDN purge |
The CDN's remaining freshness, then the same chain: still up to about 120 s |
revalidateTag plus CDN invalidate |
Purge propagation plus one regeneration; the first visitor sees the old page |
{ expire: 0 } plus CDN delete |
Purge propagation; the first visitor waits for a render |
Only an explicit call per layer shrinks the bound.
Choosing a mechanism
| Mechanism | Freshness after a change | Cost | Risk |
|---|---|---|---|
Time-based revalidate only |
Up to the window, plus stale serves | Nothing to operate | Old data on a layered path |
revalidateTag(tag, "max") |
One stale response, then fresh | A webhook, tagging discipline | CDN copy untouched |
revalidatePath |
One stale response for that route | Knowing exact paths and rewrite targets | Other pages with the same data stay stale |
Expire at once ({ expire: 0 }, CDN delete) |
No stale response | A blocking render per path | Stampede when many paths expire together |
Figure 4. Which tool for which change
when it changes?"} -->|"no"| T["Time-based revalidate,
long window"] Q1 -->|"yes"| Q2{"Old page must
never be seen again?"} Q2 -->|"yes"| X["Expire at once,
accept a blocking render"] Q2 -->|"no"| Q3{"Shared by
several pages?"} Q3 -->|"yes"| TAG["revalidateTag, max"] Q3 -->|"no"| P["revalidatePath"] TAG --> C["Then purge the CDN layer"] P --> C X --> C
How to read this: the choices are knowledge (do you know when it changes) and tolerance (may one visitor see the old page). Every branch ends at the CDN, because the framework call stops at the origin.
In practice: correcting prices on a 20,000-product storefront
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A retailer's product pages are prerendered with revalidate = 3600. Merchandisers change prices through an admin tool, sometimes in bulk before a sale. Shoppers see old prices for up to an hour, and a corrected price must appear quickly.
Starting architecture
Figure 5. The starting architecture
How to read this: the admin tool writes to the API only. No arrow tells the app or the CDN that anything changed.
Options considered
| Option | Fixes | Costs | Risk |
|---|---|---|---|
Lower revalidate to 60 |
Shorter wait | More regenerations | Still a window, and layers add up |
| Tags and webhook, no CDN purge | Origin correct at once | Tagging, a handler | CDN keeps old copies |
| Tags, webhook and CDN invalidate | Both layers corrected | Two calls, purge rate limits | One stale response per page |
The decision
| Field | Decision record |
|---|---|
| Context | Prices change at known moments; a wrong price must not linger. |
| Decision | Tag each price fetch product-<id>. The admin tool calls a handler that runs revalidateTag(..., "max") then the CDN's invalidate-by-tag call. Keep the hourly window as a safety net. Use delete only for the "wrong price" case. |
| Consequences, good | Corrections appear after one request, not an hour. |
| Consequences, bad | Two systems to keep in step; the first visitor still sees the old price. |
| Revisit when | A sale must flip for every shopper at one instant. That needs a price fetched at request time. |
Rollout and verification
- Read headers first:
x-nextjs-cacheat the origin and the CDN's cache-status header at the edge. - Test one product on staging: edit, call the webhook, watch
HITbecomeSTALEand thenHITagain. - Replay a bulk edit with the purge rate limit in mind: Vercel documents per-plan limits that count calls, not tags.
- Break the CDN call on purpose and confirm the handler reports failure.
The 2 a.m. failure
Before a sale, merchandising bulk-loads 4,000 prices. revalidateTag works, but the CDN call hits the purge rate limit and returns HTTP 429 with a retry-after header. The handler ignores the error and returns success. The origin is correct, the CDN keeps old prices, and shoppers see two prices depending on where they enter.
First three checks: (1) compare x-nextjs-cache at the origin with the CDN's status for one page; (2) search the handler's logs for 429s; (3) read the CDN's cache-status reason, which on Vercel names tag-based invalidation. Mitigation: retry after the delay given. Follow-up: batch tags per call, retry with backoff, and fail the handler when the CDN call fails.
Architect's notes
- Decide per layer, not per page. List every cache between user and data, who owns its invalidation, and what clears it.
- Prefer tags for shared data. They survive moving data between pages.
- Measure staleness. Expose a "rendered at" value in a debug header and alert on its age.
Common mistakes
- Treating
revalidateas an expiry. It marks when refreshing starts; by default the stale window lasts about a year. - Calling
revalidateTagand assuming the CDN is clean. Purge it too. - Revalidating a rewrite's source path instead of the destination route.
- Copying one vendor's header to another. Cloudflare documents that
s-maxagedisablesstale-while-revalidate. - Ignoring per-instance caches in a multi-instance deployment.
Key takeaways
stale-while-revalidate(RFC 5861) lets a cache serve a stale copy for a bounded window and revalidate without blocking; the wording is "may" and "should", and a request triggers the refresh.- Next.js ISR serves the cached page after the time passes, regenerates in the background, keeps the last good page on error, and stores HTML and RSC together.
revalidateTag(tag, "max")andrevalidatePathmark data stale and refresh on the next request;{ expire: 0 }makes the next request wait.- On-demand calls reach the Next.js server cache, not a CDN's copy. Purge the CDN separately.
- Vercel separates invalidate (serve stale, refresh) from delete (wait); deleting many paths can stampede the origin.
- Layers in series add their windows; only a call per layer shortens the bound. Keep a time-based safety net in case a webhook is lost.
Practice exercises
Exercise 1: Starter (30 min). For Cache-Control: s-maxage=300, stale-while-revalidate=60, write what a cache may do at ages 100, 320 and 400 seconds, citing RFC 5861.
Exercise 2: Intermediate (2 hours). Draw every cache between a shopper and the database in an app you know, listing each layer's TTL and the call that clears it. Mark the layers an on-demand call cannot reach.
Exercise 3: Advanced (half day). In a scratch Next.js app, add revalidate, tags and a Route Handler, run next build and next start, and log x-nextjs-cache across edit, webhook and request. Compare the sequence with Figure 1.
Related reading
In this course:
- Rendering Strategies: CSR, SSR, SSG, ISR and Streaming, where ISR sits among the strategies.
- HTTP Caching for the Front End, freshness, validation and the
Cache-Controldirectives used here. - Loading Performance and Core Web Vitals, measuring the effect on real users.
Elsewhere on the site:
- System Design: CDN and Object Storage, versioned URLs and purging.
- System Design: Advanced Caching Strategies, stampedes and invalidation on the server side.
- Cache Invalidation: Races, Leases, Versions and Dependencies, the back-end view of the same problem.