Mastering Incremental Static Regeneration and Stale-While-Revalidate in Next.js
ISR is not a magic freshness switch — it is a two-layer cache contract between your framework and the CDN. Learn how to wire tag-based revalidation, align stale-while-revalidate windows, and avoid the production pitfalls that silently break caching.
Photo by panumas nikhomkhai on PexelsISR Is a Cache Contract, Not a Rendering Mode
Most teams adopt Incremental Static Regeneration (ISR) believing they have enabled "SSR but fast." That mental model breaks the moment a marketing page shows yesterday's pricing for six hours, or an on-call engineer discovers that adding cookies() to a layout silently disabled static generation for an entire route subtree.
ISR is best understood as a freshness policy applied to a statically rendered artifact. Next.js renders the route once, serializes it into a framework-level cache, and then serves that artifact to every visitor. The revalidate window is a lease on freshness: after it expires, the next request still receives the stale artifact while a background render refreshes the cache entry. That behaviour — serve stale, regenerate in the background — is the same contract HTTP describes as stale-while-revalidate. Understanding that symmetry is what lets you compose the framework cache with a CDN edge cache without double-counting TTLs or accidentally pinning stale data forever.
This article covers the App Router implementation end-to-end: segment configuration, tag-based invalidation, header composition for edge caches, stampede control, and the operational signals that prove your cache is actually working.
The Revalidation Lifecycle Under the Microscope
Every ISR route passes through four observable phases:
- Fresh (HIT). The cached artifact exists and
now - generatedAt < revalidate. Served directly with no origin work. - Stale, trigger regeneration (STALE). The lease expired. The request receives the old artifact immediately — zero added latency — and a background regeneration is enqueued.
- Regeneration in flight. Next.js performs a single re-render for that route (requests for the same path are coalesced), re-executing cached
fetchcalls whose ownrevalidatewindows have elapsed. - Atomic swap. The new artifact replaces the old one. If regeneration throws, the stale artifact continues to be served — the failure mode is degraded freshness, not availability loss.
That fourth property is the reason ISR is operationally attractive: an origin outage manifests as an old timestamp on the page, not a 500.
Segment config vs. fetch-level config
Next.js exposes freshness at two levels, and the interaction is the most common source of confusion:
| Mechanism | Scope | Semantics |
|---|---|---|
export const revalidate = N | Route segment | Sets the default revalidation interval for the rendered route |
export const dynamic = 'force-static' | Route segment | Forces static rendering; cookies()/headers() return empty values |
fetch(url, { next: { revalidate: N } }) | Individual data dependency | Caches that response for N seconds independently of the route |
fetch(url, { cache: 'no-store' }) | Individual data dependency | Opts the entire route into dynamic rendering |
Note a breaking change that reshaped most caching strategies: as of Next.js 15, `fetch` requests are no longer cached by default. You must opt in explicitly with cache: 'force-cache' or next: { revalidate }. Code migrated from Next.js 14 that relied on implicit fetch caching is now silently dynamic.
A Production-Grade ISR Route in the App Router
The following page demonstrates the pattern I recommend: a route-level revalidate as a safety net, plus per-dependency tags for surgical invalidation.
// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
type Product = {
id: string;
slug: string;
name: string;
priceCents: number;
updatedAt: string;
};
// Safety net: even if a webhook is dropped, freshness is bounded.
export const revalidate = 300;
// Unknown slugs render on demand instead of 404-ing at the edge.
export const dynamicParams = true;
const API = process.env.API_ORIGIN;
async function getProduct(slug: string): Promise<Product | null> {
if (!API) throw new Error('API_ORIGIN is not configured');
const res = await fetch(`${API}/products/${encodeURIComponent(slug)}`, {
headers: { Authorization: `Bearer ${process.env.API_TOKEN ?? ''}` },
next: { revalidate: 300, tags: ['products', `product:${slug}`] },
});
if (res.status === 404) return null;
if (!res.ok) {
// Throwing preserves the previously cached artifact.
throw new Error(`Product fetch failed: ${res.status} ${res.statusText}`);
}
return (await res.json()) as Product;
}
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
try {
const res = await fetch(`${API}/products?limit=200`, {
next: { revalidate: 3600, tags: ['products'] },
});
if (!res.ok) throw new Error(`catalog fetch failed: ${res.status}`);
const { items } = (await res.json()) as { items: Array<{ slug: string }> };
return items.map(({ slug }) => ({ slug }));
} catch (err) {
// Never fail the build because the catalog API blinked.
console.error('[generateStaticParams] falling back to on-demand ISR', err);
return [];
}
}
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
return product
? { title: product.name, alternates: { canonical: `/products/${slug}` } }
: { title: 'Product not found' };
}
export default async function ProductPage(
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>${(product.priceCents / 100).toFixed(2)}</p>
<time dateTime={product.updatedAt}>Updated {product.updatedAt}</time>
</article>
);
}Two design decisions matter here. First, generateStaticParams returns a bounded subset (the top 200 products) rather than the entire catalog — pre-rendering 50,000 pages turns every deploy into a 40-minute build. Second, the empty-array fallback on error means a flaky upstream degrades build performance, not build success.
On-Demand Revalidation: Tags Beat Paths
Time-based revalidation is a bound on staleness, not a correctness guarantee. When an editor publishes, you want invalidation in milliseconds. revalidateTag gives you fan-out invalidation without enumerating URLs.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextResponse, type NextRequest } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
export const runtime = 'nodejs';
type Payload = { tags?: string[]; paths?: string[] };
function authorized(header: string | null): boolean {
const secret = process.env.REVALIDATE_SECRET;
if (!secret || !header) return false;
const a = Buffer.from(header);
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
if (!authorized(req.headers.get('x-revalidate-secret'))) {
return NextResponse.json({ ok: false, error: 'unauthorized' }, { status: 401 });
}
let body: Payload;
try {
body = (await req.json()) as Payload;
} catch {
return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 });
}
const tags = body.tags ?? [];
const paths = body.paths ?? [];
if (!tags.length && !paths.length) {
return NextResponse.json({ ok: false, error: 'no_targets' }, { status: 400 });
}
for (const tag of tags) revalidateTag(tag);
for (const path of paths) revalidatePath(path);
console.info('[revalidate]', { tags, paths, at: new Date().toISOString() });
return NextResponse.json({ ok: true, revalidated: { tags, paths } });
}Critical caveat: revalidatePath('/products/[slug]', 'page') invalidates only that exact parameterized path, not every product. For catalog-wide changes, tag every fetch that depends on the catalog (products) and invalidate the tag. Tags are the only mechanism that scales beyond a few dozen routes.
Stale-While-Revalidate Is an HTTP Contract
The behaviour Next.js implements internally is specified for intermediaries in RFC 5861 and formalized in RFC 9111 §4.2.4. A well-tuned edge response looks like this:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400
CDN-Cache-Control: public, s-maxage=300, stale-while-revalidate=3600Read that as a timeline:
s-maxage=60— the shared cache serves fresh for 60s.stale-while-revalidate=600— for the next 10 minutes the edge serves the stale copy and revalidates in the background in parallel.stale-if-error=86400— if the origin returns 5xx or times out, serve stale for up to 24h.CDN-Cache-Control(RFC 9213) targets intermediary caches only, letting you give the CDN a different freshness policy than the browser without breakingmax-agesemantics.
Aligning the two cache layers
This is where most production incidents originate. Your effective staleness is roughly ISR revalidate + CDN s-maxage + SWR window, and a mismatch in either direction causes visible defects:
- CDN TTL > ISR TTL: you revalidated the framework cache, but the edge is still serving the old HTML. The
revalidateTagcall appears to do nothing. - CDN TTL < ISR TTL: every edge expiry causes an origin hit against an artifact that Next.js considers fresh — wasted invalidation traffic and no freshness gain.
On Vercel, the platform derives s-maxage from your route's revalidate value automatically, so the layers stay aligned. On self-managed CDNs (CloudFront, Fastly, Cloudflare), you own that alignment explicitly.
// app/api/catalog/route.ts — explicit header control for a JSON endpoint
export const revalidate = 60;
export async function GET() {
const res = await fetch(`${process.env.API_ORIGIN}/catalog`, {
next: { revalidate: 60, tags: ['catalog'] },
});
if (!res.ok) {
return Response.json({ error: 'upstream_unavailable' }, { status: 503 });
}
return Response.json(await res.json(), {
headers: {
'Cache-Control': 'public, max-age=0, s-maxage=60, stale-while-revalidate=600',
'CDN-Cache-Control': 'public, s-maxage=60, stale-while-revalidate=600',
},
});
}Preventing the Revalidation Stampede
A tag invalidation on a high-traffic route can trigger dozens of concurrent regenerations if your framework and CDN caches both expire at once. Mitigations that hold up in production:
- Coalesce at the origin. Next.js single-flights regeneration per path, but downstream API calls are not coalesced. Wrap expensive recomputation in a Redis lock (
SET key value NX PX 10000) so only one worker executes it. - Jitter your TTLs. Instead of
revalidate = 3600on every route, use3540 + Math.floor(Math.random() * 120). Uniform TTLs create synchronized expiry waves. - Invalidate tags, not routes, on bulk edits. One webhook with
tags: ['products']beats 5,000revalidatePathcalls. - Never interpolate untrusted input into `revalidatePath`. An attacker who can hit your webhook with arbitrary paths can force regeneration of every route.
The Personalization Leak Nobody Warns You About
If a route uses cookies(), headers(), or searchParams, Next.js opts it out of static rendering — revalidate is ignored. That is the safe failure. The dangerous failure is the reverse: reading user-specific state outside those APIs (a module-level mutable variable, a global Map keyed by request, or a cache tag shared across tenants) inside a route that is statically regenerated. The rendered artifact then gets served to every subsequent visitor.
The rule is absolute: an ISR route must be a pure function of its route parameters and cache tags. Personalization belongs in a client component, middleware, or a dynamic segment.
Observability: Prove the Cache Works
Never trust a caching strategy you cannot measure.
# Vercel: HIT | MISS | STALE | BYPASS
curl -sI https://example.com/products/blue-widget | grep -i x-vercel-cache
# Generic CDN: check age vs. cache-control
curl -sI https://example.com/products/blue-widget | grep -iE '^(age|cache-control|cdn-cache-control):'Instrument three signals: hit ratio by route, regeneration duration (p95 of the background render), and stale-serving duration (time between expiry and successful regeneration). A rising stale-serving duration is the earliest indicator that your upstream is degrading — long before error rates move.
Trade-off Matrix
| Strategy | TTFB | Freshness | Origin load | Best for |
|---|---|---|---|---|
| Fully static (SSG) | Lowest | Deploy-bound | ~0 | Marketing pages, docs |
| ISR (time-based) | Lowest | Bounded by revalidate | Very low | Catalogs, content hubs |
| ISR + tag invalidation | Lowest | Near-real-time | Low | CMS-driven sites |
| SSR / dynamic | Higher | Exact | High | Dashboards, checkout |
Client SWR (useSWR) | Lowest HTML | Per-session | High (client) | User-specific data |
Production Checklist
- Every cached
fetchcarries both arevalidateand atagsarray. generateStaticParamspre-renders a bounded subset;dynamicParams = truehandles the tail.- Webhook revalidation validates a secret with
timingSafeEqual. - CDN
s-maxageis aligned with the route'srevalidatevalue. stale-if-erroris set so upstream failures degrade to stale, not 5xx.- No
cookies(),headers(), orsearchParamsreads inside ISR segments. - TTLs are jittered to prevent synchronized expiry.
x-vercel-cache(orAge) is scraped into your metrics pipeline.- Revalidation events are logged with tag, path, and timestamp.
Common Pitfalls to Avoid
- Assuming Next.js 14 fetch caching still applies. In Next.js 15+,
fetchdefaults tono-store— supplynext: { revalidate }explicitly. - Setting `revalidate = 0` expecting ISR. It opts the route into dynamic rendering, not zero-second ISR.
- Forgetting that `generateMetadata` also triggers cached fetches. Duplicate uncached fetches there will quietly make the route dynamic.
- Invalidating a parent tag but not the child.
revalidateTag('products')does not clearproduct:blue-widget; invalidate both or nest them deliberately. - Ignoring the `cacheComponents` migration path. Next.js 16's
use cachedirective withcacheLife()andcacheTag()expresses the same semantics at the function level; segment-levelrevalidateremains supported, but new code should be written with the function-level model in mind.
When ISR Is the Wrong Tool
ISR is inappropriate for anything where a stale read is a correctness bug: inventory counts during a flash sale, financial balances, permission-gated content, and real-time collaborative state. For those, use dynamic rendering with no-store and invest in origin performance instead. ISR trades freshness for latency — and that trade is only valid when you can quantify the acceptable staleness window in the product requirements, not in the pull request description.
Sources & Technical References
- Next.js Documentation — Incremental Static Regeneration (App Router): https://nextjs.org/docs/app/guides/incremental-static-regeneration
- Next.js Documentation — `revalidateTag`: https://nextjs.org/docs/app/api-reference/functions/revalidateTag
- Next.js Documentation — `revalidatePath`: https://nextjs.org/docs/app/api-reference/functions/revalidatePath
- Next.js Documentation — Route Segment Config: https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
- Next.js Documentation — `fetch` API reference and caching defaults: https://nextjs.org/docs/app/api-reference/functions/fetch
- Next.js Documentation — Incremental Static Regeneration (Pages Router): https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration
- Next.js Blog — Next.js 15 release notes (fetch caching default changes): https://nextjs.org/blog/next-15
- RFC 9111 — HTTP Caching (Fielding, Nottingham, Reschke, eds., IETF): https://www.rfc-editor.org/rfc/rfc9111
- RFC 5861 — HTTP Cache-Control Extensions for Stale Content (Nottingham, IETF): https://www.rfc-editor.org/rfc/rfc5861
- RFC 9213 — Targeted HTTP Cache Control (Ludin, Nottingham, Wu, IETF): https://www.rfc-editor.org/rfc/rfc9213
- MDN Web Docs — `Cache-Control` header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- Vercel Documentation — CDN caching and cache headers: https://vercel.com/docs/infrastructure/cdn/caching
- Cloudflare Documentation — Cache-Control and edge cache behaviour: https://developers.cloudflare.com/cache/concepts/cache-control/
- AWS Documentation — Amazon CloudFront: managing how long content stays in the cache: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.

Landing Page Experimentation at 50M Sessions: Client-Side A/B Testing vs. Edge Decisioning vs. Server-Side Flags — Flicker, INP, and Cost per Million Decisions Benchmarked
At 50 million monthly sessions, where your A/B test makes its decision stops being a tooling preference and becomes a Core Web Vitals and P&L decision. This is a head-to-head benchmark of client-side DOM mutation, edge middleware bucketing, and server-side feature flags — measured on CLS flicker, INP, and true cost per million decisions.