3.5 — ISR at catalog scale¶
Module 3 · Lesson 5 · 🔴 Advanced · ~40 min
What you'll learn¶
- How to apply ISR to 2.4M SKUs without a 9‑hour build
- On‑demand revalidation architecture driven by catalog events
- Cache stampedes, cold long tails, and the failure modes at this scale
- Choosing between ISR, CDN SWR, and PPR for a given page
The scale problem¶
Naively, generateStaticParams over all products × markets is tens of millions of pages. Even at
50 ms each with 32‑way parallelism, that's days. And it would be wasted: product page traffic
follows a steep power law.
Aurora's actual distribution:
| Segment | Products | % of PDP traffic | Cumulative |
|---|---|---|---|
| Top 1K | 1,000 | 31% | 31% |
| Next 19K | 19,000 | 38% | 69% |
| Next 80K | 80,000 | 19% | 88% |
| Long tail | 280,000 | 12% | 100% |
88% of traffic hits 100K products. That's the number that makes this tractable.
The hybrid strategy¶
// app/p/[slug]/page.tsx
export const revalidate = 3600; // fallback window for anything not purged on demand
export const dynamicParams = true; // generate the long tail on first request
export async function generateStaticParams() {
// Build only what's worth building. Everything else is generated on demand
// and then cached, which is nearly as good and costs 1/20th the build time.
const products = await getTopProductsBySales({ limit: 20_000 });
return products.map((p) => ({ slug: p.slug }));
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return <ProductView product={product} />;
}
| Approach | Build time | Coverage at deploy | First‑visit TTFB (uncovered) |
|---|---|---|---|
| All 380K | ~5.3 h | 100% | n/a |
| Top 20K + on‑demand | ~17 min | 69% | ~700 ms once, then cached |
| Top 1K + on‑demand | ~50 s | 31% | ~700 ms once, then cached |
Aurora ships the middle row. The 17‑minute build is acceptable, deploys stay same‑day, and the uncovered long tail pays a one‑time render that a real user rarely notices (they're on the tail because almost nobody visits).
Choosing the limit¶
Balance build time against the fraction of unique daily visitors who'd hit a cold page.
// scripts/analyze-static-params-cutoff.ts
// Answers: "what fraction of daily PDP views hit a page that isn't prebuilt?"
const views = await getDailyPdpViewsByProduct(); // sorted desc
const total = views.reduce((s, v) => s + v.count, 0);
let cumulative = 0;
for (const cutoff of [1_000, 5_000, 20_000, 50_000, 100_000]) {
cumulative = views.slice(0, cutoff).reduce((s, v) => s + v.count, 0);
const buildMinutes = (cutoff * 55) / 1000 / 60; // ~55ms/page measured locally
console.log(
`${cutoff.toLocaleString().padStart(7)} pages: ` +
`${((cumulative / total) * 100).toFixed(1)}% coverage, ` +
`~${buildMinutes.toFixed(0)} min build`,
);
}
Rule of thumb: prebuild until the marginal 10K pages add less than 2% coverage. Beyond that, the build time buys nothing.
On‑demand revalidation¶
Time‑based revalidate alone is wrong for commerce: either your window is short (and you're
re‑rendering constantly) or it's long (and prices are stale). The answer is event‑driven purging
with a long time‑based window as a safety net.
PIM / ERP / pricing service
│ product.updated, price.changed, inventory.low, content.published
▼
Event bus (Kafka / SNS)
│
▼
Revalidation worker ── batches, dedupes, rate-limits ──▶ POST /api/revalidate
│
revalidateTag(`product-{id}`)
+ CDN surrogate-key purge
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { after } from 'next/server';
const MAX_BATCH = 500;
export async function POST(req: NextRequest) {
const raw = await req.text();
// 1. Verify the signature. An open revalidation endpoint is a DoS vector:
// an attacker purges your cache in a loop and your origin melts.
const expected = createHmac('sha256', process.env.REVALIDATE_SECRET!)
.update(raw)
.digest('hex');
const received = req.headers.get('x-signature') ?? '';
if (
received.length !== expected.length ||
!timingSafeEqual(Buffer.from(received), Buffer.from(expected))
) {
return new NextResponse('Unauthorized', { status: 401 });
}
const { events } = JSON.parse(raw) as { events: CatalogEvent[] };
if (events.length > MAX_BATCH) {
return NextResponse.json({ error: `Batch too large (max ${MAX_BATCH})` }, { status: 413 });
}
// 2. Dedupe — a bulk price update often sends the same product many times
const tags = new Set<string>();
for (const e of events) {
switch (e.type) {
case 'product.updated':
case 'product.price_changed':
tags.add(`product-${e.productId}`);
for (const c of e.categoryIds ?? []) tags.add(`category-${c}`);
break;
case 'category.updated':
tags.add(`category-${e.categoryId}`);
break;
case 'content.published':
tags.add(`cms-${e.contentId}`);
break;
}
}
// 3. Guard against accidental full purges
if (tags.size > 200) {
logWarn('revalidate_large_batch', { count: tags.size });
}
for (const tag of tags) revalidateTag(tag);
// 4. Purge the CDN too — Next.js's cache is not the only cache
after(() => purgeCdnSurrogateKeys([...tags]));
return NextResponse.json({ revalidated: [...tags], count: tags.size });
}
Batching in the worker matters. A seasonal price update touching 50K SKUs, sent as 50K individual webhooks, will hammer both your revalidation endpoint and your origin as everything regenerates simultaneously. Batch on a 5–10 second window, dedupe, and rate‑limit:
// workers/revalidation-worker.ts
const BATCH_WINDOW_MS = 5_000;
const MAX_TAGS_PER_SECOND = 200;
const pending = new Set<string>();
let timer: NodeJS.Timeout | null = null;
export function enqueue(event: CatalogEvent) {
pending.add(tagFor(event));
timer ??= setTimeout(flush, BATCH_WINDOW_MS);
}
async function flush() {
timer = null;
const batch = [...pending].slice(0, MAX_TAGS_PER_SECOND);
batch.forEach((t) => pending.delete(t));
await postSigned('/api/revalidate', { events: batch.map(toEvent) });
// More waiting? Schedule the next window — never fire them all at once.
if (pending.size) timer = setTimeout(flush, 1_000);
}
Cache stampedes¶
The classic failure: a popular page's cache entry expires, and 4,000 concurrent requests all miss and all render it simultaneously. Your origin sees 4,000× normal load for one page.
Three defenses, use all of them:
1. stale-while-revalidate semantics¶
ISR already does this: an expired entry is served stale while one background regeneration runs. Make sure your CDN layer does it too:
The stale-while-revalidate=86400 is the important half. Without it, expiry means a miss; with it,
expiry means "serve stale, refresh behind the scenes."
2. Request coalescing at the origin¶
If your CDN doesn't coalesce (most do — check for "request collapsing" or "origin shielding"), add a lock:
// lib/single-flight.ts
// Ensures only one in-flight computation per key, per instance.
const inflight = new Map<string, Promise<unknown>>();
export function singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = inflight.get(key) as Promise<T> | undefined;
if (existing) return existing;
const promise = fn().finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
For cross‑instance coalescing, use a short Redis lock:
export async function singleFlightDistributed<T>(
key: string, fn: () => Promise<T>, ttlMs = 10_000,
): Promise<T> {
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', { NX: true, PX: ttlMs });
if (!acquired) {
// Someone else is computing it — wait briefly and read the cache
await sleep(120);
const cached = await redis.get(`data:${key}`);
if (cached) return JSON.parse(cached);
}
try {
const value = await fn();
await redis.set(`data:${key}`, JSON.stringify(value), { PX: ttlMs * 6 });
return value;
} finally {
await redis.del(lockKey);
}
}
3. Jittered expiry¶
If 20,000 pages are built in the same deploy with revalidate: 3600, they all expire in the same
second an hour later. Add jitter:
// Spread expiry over a window so regeneration is smooth, not spiky
export function jitteredRevalidate(baseSeconds: number, spreadPct = 0.2): number {
const spread = baseSeconds * spreadPct;
return Math.round(baseSeconds - spread / 2 + Math.random() * spread);
}
Route‑level export const revalidate must be a static value, so apply jitter at the Data Cache
level (next: { revalidate: jitteredRevalidate(3600) }) or in your CDN's s-maxage computation.
The cold long tail¶
280K products aren't prebuilt. When someone visits one:
Request → Full Route Cache MISS → render (data fetch + RSC) ~700ms → cache → respond
↑
Every subsequent visitor gets ~20ms from cache
That's usually fine. But watch three things:
1. Bot traffic. Search engine crawlers walk your entire catalog. A crawler hitting 280K cold pages produces 280K origin renders. Mitigations:
// Pre-warm ahead of the crawler using your sitemap ordering
// scripts/warm-cache.ts — run nightly, rate-limited
const CONCURRENCY = 8;
const RPS = 20;
const urls = await getSitemapUrls({ segment: 'long-tail', limit: 20_000 });
await pMap(urls, async (url) => {
await fetch(url, { headers: { 'x-cache-warm': '1' } });
await sleep(1000 / RPS * CONCURRENCY);
}, { concurrency: CONCURRENCY });
Also: keep your sitemap prioritized so crawl budget goes to pages that matter, and make sure
revalidate is long enough that a crawler doesn't re‑trigger renders on its second pass.
2. Discontinued products. 280K long‑tail pages include products that no longer exist. Serving
notFound() after a 700 ms render is a waste; keep a cached "does this slug exist" bloom filter or
a small lookup so 404s are fast and cacheable.
// Cache the negative result too — 404s should be cheap
export async function generateStaticParams() { /* … */ }
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // cached, including the null result
if (!product) notFound();
return /* … */;
}
Ensure your CDN caches 404s for a modest TTL (5–15 min). An uncached 404 path is a favorite target for scrapers.
3. Deploy invalidation. On most platforms, a new deployment invalidates the Full Route Cache (build IDs change). With 8 deploys a day, your "cache" is repeatedly cold. Two mitigations: prebuild the top‑N (so the highest‑traffic pages are warm at deploy), and keep the Data Cache shared and deploy‑independent (Redis handler) so a route re‑render is cheap even when the route cache is cold — it only re‑renders, it doesn't re‑fetch.
This is a key reason to separate the two layers in your head: Full Route Cache = the rendered output; Data Cache = the inputs. Losing the first on deploy is survivable if you keep the second.
When ISR isn't the answer¶
| Situation | Better option |
|---|---|
| Prices vary per user segment | PPR — static shell, dynamic price hole (3.6) |
| Inventory must be exact | Dynamic hole or client fetch; never cache inventory |
| Content changes every few seconds | CDN with short s-maxage + SWR; ISR churn isn't worth it |
| Page is unique per user | SSR with no-store |
| Millions of low‑traffic URLs (search queries, filter combos) | Don't cache the combinatorial space; cache the data, render dynamically |
That last row is important for PLPs. /c/womens-knitwear?color=navy&size=m&sort=price_asc&page=3
is one of hundreds of thousands of combinations per category. Caching rendered HTML for all of them
is pointless — the hit ratio would be near zero. Cache the product data in the Data Cache and
render the combination dynamically; the render is fast because the data is warm.
// app/c/[slug]/page.tsx
export const revalidate = 300;
export default async function CategoryPage({ params, searchParams }) {
const { slug } = await params;
const filters = await searchParams; // makes this render dynamic
// The DATA is cached and shared across all filter combinations;
// only the filtering/rendering is per-request, and that's cheap.
const [category, allProducts] = await Promise.all([
getCategory(slug), // Data Cache, 1h
getCategoryProducts(slug), // Data Cache, 5min
]);
const products = applyFilters(allProducts, filters);
return <CategoryView category={category} products={products} filters={filters} />;
}
For very large categories this doesn't work (you can't hold 40K products in memory per request) — push filtering into the search service and cache its responses keyed by the normalized filter set. See 7.3.
Aurora's results¶
| Metric | Before (full SSR) | After (hybrid ISR) |
|---|---|---|
| Build time | 6 min (no prebuild) | 17 min |
| PDP p75 TTFB | 910 ms | 60 ms |
| Origin renders/day | 393K | 34K |
| Origin compute | $34K/mo | $4.1K/mo |
| Price staleness p95 | 0 s | 40 s (event‑driven purge) |
| CDN HTML hit ratio | 4% | 94% |
The 40‑second price staleness was the only contested trade‑off. It was resolved by moving promotional pricing to a dynamic hole — 3.6.
Common mistakes¶
| Mistake | Cost |
|---|---|
generateStaticParams over the whole catalog |
Multi‑hour builds; can't deploy same‑day |
Time‑based revalidate only |
Stale prices or constant regeneration |
| Unsigned revalidation endpoint | Trivial origin DoS |
| No batching in the revalidation worker | Regeneration storm on bulk updates |
| No jitter on expiry | Synchronized stampede every hour |
| Caching inventory | Overselling |
| Caching filter/sort combinations | Near‑zero hit ratio; cache the data instead |
| Ignoring deploy‑time cache invalidation | Cold cache 8× a day |
Not purging the CDN alongside revalidateTag |
Next.js is fresh, the CDN still serves stale |
Lab 3.5 — Size your ISR strategy¶
- Pull PDP views per product for 30 days. Compute cumulative coverage at 1K/5K/20K/50K/100K.
- Measure your per‑page build time locally, then compute build duration for each cutoff. Pick the knee of the curve.
- Implement
generateStaticParamsat that cutoff withdynamicParams: true. - Build the revalidation endpoint with signature verification, batching, and CDN purge.
- Add jitter to Data Cache TTLs.
- Load‑test a stampede: expire a popular page's entry and fire 500 concurrent requests. Count origin renders. It should be 1, not 500.
- Measure TTFB and CDN hit ratio before/after.
Checklist¶
-
generateStaticParamscovers the traffic knee, not the whole catalog -
dynamicParams: truefor the long tail - Event‑driven revalidation, signature‑verified, batched, rate‑limited
- CDN surrogate‑key purge happens alongside
revalidateTag - Jittered TTLs
- Stampede protection verified under load, not assumed
- 404s are fast and cached
- Cache warming for the long tail ahead of crawlers
- Data Cache survives deploys (shared handler), even if the Route Cache doesn't