The 60-Second Page: Refreshing Static HTML in the Background
Your product pages are prerendered — fast, cheap, and now twelve hours stale, because nobody rebuilds the site when a price changes. The catalog team publishes at 9 am and the HTML still shows yesterday's data until someone kicks the deploy. Incremental Static Regeneration is the answer to exactly this gap: a static page can regenerate itself in the background once it expires, and visitors keep getting the fast cached version the entire time. No full rebuild, no deploy, per-page granularity.
The knob is one export on the page or layout:
// app/product/[slug]/page.tsx
export const revalidate = 60; // seconds
export default async function ProductPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const product = await getProduct(slug); // runs at build AND at each regeneration
return <ProductDetail product={product} />;
}
Follow one visitor through an expiry, because the timing is the whole feature and it is routinely explained wrong. The page was rendered at build time. At now + 60s, a request arrives. The visitor is served the existing (now stale) static file immediately, and that same request triggers the regeneration in the background. When the render finishes, the new HTML replaces the old in the cache, and every subsequent visitor gets it. So the worst-case staleness is roughly revalidate + regeneration time + one request — and no visitor ever pays the render cost, which is what distinguishes ISR from dynamic rendering.
The failure path is deliberately forgiving: if the regeneration throws — the CMS is down, the page's data source 500s — Next logs the error and keeps serving the last successfully generated version. Your product page shows yesterday's price rather than an error page, which is the correct trade for most content. The cost of that forgiveness is observability: nothing visible tells you the regen is failing, so pair ISR pages with logging or uptime checks on the regeneration path.
revalidate exists at two scopes, and conflating them is a common bug:
| Scope | Syntax | Governs |
|---|---|---|
| Segment (page/layout) | export const revalidate = 60 |
how often this route's HTML regenerates |
| Fetch (data) | fetch(url, { next: { revalidate: 60 } }) |
how long this response sits in the data cache |
They compose. A page whose HTML revalidates hourly but whose price fetch revalidates every 60 seconds still shows an hour-old shell; a page with a static shell and a 60-second data fetch shows fresh prices inside stale chrome. Note the special values on the data side: revalidate: 0 means no data caching (every render refetches — effectively dynamic), and cache: "no-store" is its explicit sibling; on the segment side, revalidate = false pins the page static forever, overriding any inherited value. A layout's revalidate is inherited by pages beneath it unless the page exports its own — so a root-layout value of 3600 quietly sets the floor for the whole app, and checking inherited segment config is the first debugging step when a page revalidates "too often" or "never."
One version note that interviewers like: ISR as a named feature arrived in Next.js 9.5-era releases for the Pages Router, where a missing revalidate meant static-forever; in the App Router the semantics are the same but the defaults around data fetching differ by version (Next 14's fetch cache versus Next 15's uncached-by-default fetches), so state the behavior you mean rather than the version number alone. The invariant across all versions: revalidate turns a build-time artifact into a living document without giving up the file-serving speed that made static attractive in the first place — the reason a news site can have 50,000 pages, single-digit-millisecond TTFB, and content that is at most minutes old.
Event-Driven Freshness: revalidateTag and revalidatePath from Your Webhook
Time-based revalidation answers "refresh every N seconds." The sharper question is "refresh now" — the CMS editor hits publish, and the page should reflect the change in seconds, not on the next expiry boundary. Next.js exposes that as on-demand revalidation: two functions you call from server-side code after a write.
// app/api/revalidate/route.ts — called by the CMS webhook
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const { secret, type, slug } = await request.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ revalidated: false }, { status: 401 });
}
if (type === "post") {
revalidateTag(`post:${slug}`); // tag granularity
revalidatePath(`/blog/${slug}`); // path granularity
} else if (type === "nav") {
revalidatePath("/", "layout"); // every page under the root layout
}
return NextResponse.json({ revalidated: true, now: Date.now() });
}
Both functions work by invalidating caches server-side; the next request for the affected content renders fresh. The difference is granularity, and choosing wrong is the classic mistake:
| Function | Invalidates | Right when |
|---|---|---|
revalidateTag(tag) |
every cache entry carrying the tag | one write invalidates many URLs (a product appearing on 40 category/search pages) |
revalidatePath(path, type?) |
one route, or a layout's whole subtree | the write maps 1:1 to a URL |
Tags require data to carry them. Tag your fetches at data time, invalidate at write time:
const product = await fetch(api(`/products/${id}`), {
next: { tags: [`product:${id}`, "catalog"] },
});
// later, after PATCH /products/:id succeeds:
revalidateTag(`product:${id}`); // every page that fetched this product refreshes
This is the design that scales: invalidate by the entity you wrote, not by the list of pages you can guess it appears on — the page list is unknowable (search, sort orders, related rails), while the tag travels with the data everywhere it is used. Use revalidatePath for structural things tags cannot express: a new post changes /blog and the sitemap, and a nav change touches every page.
Wiring the trigger is where security lives. The webhook endpoint must verify its caller — a shared secret header at minimum, a signature check for anything public (Verifying Webhook Signatures is the model). An unauthenticated revalidate endpoint is a denial-of-service lever: anyone can force your most expensive pages to render on every hit. You can also skip the HTTP round trip entirely by calling these functions from a Server Action after a successful mutation — the write path and the invalidation path execute in the same request, so there is no window where the database is ahead of the cache.
Two semantics to state precisely in interviews. First, invalidation takes effect for the next request — revalidatePath does not render anything itself; it marks the entry stale so the following visitor pays the one-time render. Second, both functions run during a request and have route-scoped effect in the current release line: when called from a Server Action, only the affected route's caches are revalidated for that request's response — plan the redirect or router.refresh() around it so the user who triggered the write actually sees their own change. And remember that on-demand invalidation composes with time-based: a page with revalidate = 3600 and a correct tag invalidation is both event-fresh and self-healing — if the webhook is lost, the hourly timer still catches up. Belt and suspenders, and the right answer when an interviewer asks what happens when the CMS webhook fails.
What ISR Does Not Do: Expiry Myths, Multi-Instance Races, and Cold Visitors
ISR is explained loosely in most articles, which produces engineers who are surprised in production. Four behaviors are worth knowing exactly, because each one has produced a real incident somewhere.
Nothing regenerates on a timer. revalidate = 60 does not schedule a background job at minute one. Regeneration is triggered by a request arriving after the expiry — if the product page gets zero traffic for a day, its HTML is a day old until someone visits, at which point they get the stale copy and kick the refresh. For a traffic-free but publicly visible page (an admin preview link, a long-tail URL shared once), that is precisely the wrong tool: the visitor sees the oldest content precisely when the page needed to look fresh. On-demand revalidation from the write path fixes this case.
Multi-instance deployments need coordination. With several server instances holding their own cache, the first request after expiry on instance A triggers regeneration there; instance B still holds its own copy and will regenerate independently when it expires. In classic self-hosted ISR this could even produce duplicate renders of the same key within one instance — multiple requests hitting the same expired page simultaneously may each start a regeneration, with the last write winning. Next.js keeps the duplicate bounded and the result deterministic (same data → same page), but the CPU spike is real: budget for thundering-herd renders after quiet periods — a deploy, a viral link, or a nightly cron touching thousands of pages can wake thousands of regenerations at once. Platforms with a shared cache layer (Vercel's blob-backed ISR, a CDN in front with s-maxage) collapse most of this, which is one concrete reason managed platforms "just work" here and self-hosted setups need an eviction plan.
The CDN sits between your revalidate and your user. Static responses carry cache headers the edge honors; a PoP that cached the page for 60 seconds keeps serving its copy through your invalidation. The practical numbers: user-perceived freshness ≈ max(CDN TTL, revalidate + regen) in the worst case. Teams that need tighter bounds lower edge TTLs for hot routes or accept the seconds of lag; teams that never think about it discover it via a support ticket from a user whose "updated" page updates on refresh from a different network.
ISR is not a substitute for per-request correctness. The regime table makes the boundary visible:
| Need | Strategy |
|---|---|
| Identical for everyone, changes rarely | Static Generation |
| Identical for everyone, tolerates seconds–minutes of lag | ISR (time + on-demand revalidation) |
| Differs per user, or must be exact per request | dynamic rendering |
| Fast first paint of fresh data | dynamic rendering + Streaming with Suspense Boundaries |
Serving one user's session-specific page from ISR is the cardinal sin — the cache does not know it is serving Alice's page to Bob, and no revalidate value fixes a wrong cache key. If any part of the output varies by cookie, header, or query string, that part belongs in the dynamic half of the tree. The engineering skill ISR rewards is the one this whole phase trains: knowing which of the four regimes each segment of the page belongs to — and the next topic, Forcing Dynamic Rendering, is how you declare the dynamic side with the same precision that revalidate declares this one.
Quiz
1. A page exports revalidate = 60 and has had no traffic for an hour. What has the server been doing with it?
2. A product update must refresh every page that displays that product — its detail page, 12 categories, search, and the home rail. Which approach scales?
3. What happens when an ISR page's regeneration throws because the CMS is down?
4. A dashboard page renders each user's private data and currently exports revalidate = 300. What is the correct assessment?
Flashcards
Question
What does export const revalidate = N do on a page or layout?
Click to reveal answer
Answer
Marks the route's HTML as regenerating every N seconds: after expiry the next request is served the stale file immediately while a fresh render replaces it in the background. Inherited by child segments unless the child exports its own; false pins it static forever.
Question
Segment-level revalidate vs fetch-level next: { revalidate } — what does each cache?
Click to reveal answer
Answer
Segment revalidate governs how often the route's HTML regenerates. fetch revalidate (next: { revalidate: 60 }) governs how long that data response sits in the data cache. They compose: stale shell with fresh data is a deliberate, common combination.
Question
revalidateTag vs revalidatePath — when is each the right invalidation?
Click to reveal answer
Answer
revalidateTag(tag) invalidates every cached entry whose fetches carried that tag — right when one entity write touches unenumerable pages. revalidatePath(path, 'layout'?) invalidates one route or a layout subtree — right for structural changes (new post updates /blog). Call both from a verified webhook or a Server Action after the write.
Question
The three ISR production traps, in one line each.
Click to reveal answer
Answer
1) No traffic → no regeneration: cold pages serve arbitrarily stale HTML until the first visitor triggers refresh. 2) Multiple instances regenerate independently; bursts after quiet periods can herd — budget CPU or centralize the cache. 3) CDN TTLs sit in front of revalidate, so observed freshness is max(edge TTL, revalidate).
Question
When does an ISR page's regeneration run, and what does the triggering visitor receive?
Click to reveal answer
Answer
On the first request after the revalidate window elapses. That visitor receives the stale (previous) copy immediately; the regeneration runs in the background and the new HTML serves everyone after it completes. If the regeneration throws, the old copy keeps serving and the error is logged.
Revision Notes
Key Takeaways
- 1. revalidate turns a static page into a self-refreshing one: stale file serves instantly while a background render replaces it
- 2. Regeneration is request-triggered, not scheduled — an idle page serves arbitrarily stale HTML until its first visitor after expiry
- 3. On-demand freshness comes from revalidateTag (entity-scoped, scales to unenumerable URLs) or revalidatePath (URL/subtree), fired from a verified webhook or Server Action
- 4. Failed regenerations keep the last good version — correct for content, invisible without logging, so monitor the regeneration path
- 5. ISR is strictly for visitor-identical output; per-user pages in the static cache are a correctness bug no TTL can fix
Interview Tips
- • "Explain ISR" — file serving + background regeneration on the first request after expiry; the triggering visitor gets the stale copy; state the worst-case staleness formula (revalidate + regen + one request)
- • "Time-based vs on-demand revalidation" — timers for self-healing fallback, tags/paths for event-driven freshness; a strong answer names the tag-at-read/invalidate-at-write pattern and its webhook security requirement
- • "Our updated content still shows old data" — walk the stack: CDN TTL → segment revalidate window → request-triggered regen (needs traffic) → multi-instance copies; each layer has its own fix
- • "Can an ISR page show per-user data?" — no: cache key is the path; this is the interview's dividing line between caching strategy and correctness — dynamic rendering or client fetching is mandatory there
Cheat Sheet
Incremental Static Regeneration
The regime: static file serving with background refresh — single-digit-ms TTFB, no per-visitor render, content at most ~revalidate + regen old.
Two scopes:
export const revalidate = 60; // route HTML refresh cadence
const data = await fetch(url, { next: { revalidate: 60, tags: ["product:1"] } }); // data cache TTL + tag
Layout value is inherited; a page's own export overrides it; false = static forever; fetch revalidate: 0 / no-store = never cache the data.
Request lifecycle at expiry: request → stale file served instantly → that request starts the background regen → new HTML replaces old → later visitors get it. Regeneration failure keeps the last good copy (log it!).
On-demand invalidation (after a write):
revalidateTag(`product:${id}`); // entity-scoped: every page that fetched it
revalidatePath(`/blog/${slug}`); // URL-scoped; revalidatePath("/", "layout") = subtree
Trigger from a signature-verified webhook or a Server Action; unauthenticated = DoS lever. Tag at read time, invalidate the tag at write time.
Limits: request-triggered (idle pages stay stale) · multi-instance = independent copies + herd risk after quiet periods · CDN TTL adds to observed staleness · never key per-user output on ISR — the cache is per path, not per session.
Regime picker: rarely changing → SSG · seconds–minutes of lag OK → ISR · per-request/per-user → dynamic rendering · fresh-but-fast → dynamic + streaming.