Skip to content

    Frontend System Design

    Incremental Static Regeneration and On-Demand Revalidation

    Decide how stale a cached page may get: learn stale-while-revalidate, time-based and on-demand revalidation in Next.js, and why CDNs keep serving old copies.

    By Published 12 min read

    • frontend-system-design
    • isr
    • revalidation
    • stale-while-revalidate
    • cdn
    Share

    Standard lessonintermediateFrontend System Design

    Before this: Rendering Strategies: CSR, SSR, SSG, ISR and Streaming, CDN and Edge Caching From the Browser's Side (coming soon)

    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, revalidatePath and 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.

    1. 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.
    2. Revalidation is lazy. Marking something stale renders nothing. The next request does the work.
    3. 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

    stateDiagram-v2 accTitle: States of a cached page under stale-while-revalidate accDescr: An entry is fresh until its revalidate time passes, then stale. A tag or path invalidation also marks it stale. The first request on a stale entry gets the stale page and starts regeneration. Success stores a fresh entry. A failed render keeps the old entry. If the window has ended or the entry was expired at once, the next request waits for a render. [*] --> Fresh: generated and stored Fresh --> Stale: revalidate time passes Fresh --> Stale: tag or path invalidated Stale --> Regenerating: next request (gets stale page) Regenerating --> Fresh: render succeeds Regenerating --> Stale: render throws, last good page kept Stale --> Blocked: window ended or expired at once Blocked --> Fresh: request waits for render

    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-cache response header reports HIT, STALE, MISS or REVALIDATED.

    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 with type: "layout" a layout and everything beneath it. A pattern like /products/[id] needs type. 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 with fetch(url, { next: { tags: [...] } }) or cacheTag inside 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

    A page is cached in the browser, a CDN, and the Next.js server cache; a webhook reaches only the lastBrowser to CDN (GET); CDN to Next.js server cache (on miss or refresh); Next.js server cache to Product API (regenerate); Admin tool to Revalidate handler (webhook); Revalidate handler to Next.js server cache (mark stale)ORIGINBrowser: HTTP cacheBrowserHTTP cacheCDN: copy honours s-maxageCDNcopy honourss-maxageNext.js server cache: HTML + RSC, tagsNext.js servercacheHTML + RSC, tagsProduct API: source of truthProduct APIsource of truthAdmin tool: price editedAdmin toolprice editedRevalidate handler: revalidateTagRevalidatehandlerrevalidateTagGETon miss or refreshregeneratewebhookmark stale

    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

    sequenceDiagram accTitle: On-demand revalidation across the origin and a CDN accDescr: An admin edits a price. A webhook calls the revalidate handler, which marks the tag stale in the Next.js cache and then calls the CDN purge API. The next visitor gets the stale copy at once, the CDN refreshes in the background from the origin, and the following visitor gets the new price. participant A as Admin tool participant H as Revalidate handler participant N as Next.js cache participant C as CDN participant V as Visitor A->>H: price changed (webhook) H->>N: revalidateTag("product-123", "max") H->>C: invalidate tag V->>C: GET /products/123 C-->>V: stale page, sent at once C->>N: refresh in background N-->>C: regenerated page V->>C: GET /products/123 C-->>V: page with the new price

    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 setting Vercel-CDN-Cache-Control on routes ISR already caches.
    • Cloudflare purges by URL, tag, hostname, prefix or everything on every plan. Its cache-control page says stale-while-revalidate is not supported with the Workers Cache API and that using s-maxage disables it, because s-maxage implies proxy-revalidate under 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

    flowchart TD accTitle: Choosing a revalidation mechanism accDescr: A decision tree. If the change time is unknown, use time-based revalidation. If it is known and the old page must never be seen again, expire at once. Otherwise use a tag for data shared by several pages or a path for one page. Every branch ends with a CDN purge. Q1{"Do you know
    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

    NOTE

    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

    Starting architecture: a CDN in front of a Next.js app that revalidates hourlyShopper to CDN (GET); CDN to Next.js app (on refresh); Next.js app to Product API (regenerate); Admin tool to Product API (write)ShopperShopperCDN: honours s-maxageCDNhonours s-maxageNext.js app: revalidate = 3600Next.js apprevalidate = 3600Product APIProduct APIAdmin tool: edits pricesAdmin tooledits pricesGETon refreshregeneratewrite

    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

    1. Read headers first: x-nextjs-cache at the origin and the CDN's cache-status header at the edge.
    2. Test one product on staging: edit, call the webhook, watch HIT become STALE and then HIT again.
    3. Replay a bulk edit with the purge rate limit in mind: Vercel documents per-plan limits that count calls, not tags.
    4. 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 revalidate as an expiry. It marks when refreshing starts; by default the stale window lasts about a year.
    • Calling revalidateTag and 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-maxage disables stale-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") and revalidatePath mark 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.

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments