Skip to content

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

2.4M SKUs · 380K parent products · 11K categories · 40 markets · 14 locales

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:

Cache-Control: public, s-maxage=300, stale-while-revalidate=86400

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

  1. Pull PDP views per product for 30 days. Compute cumulative coverage at 1K/5K/20K/50K/100K.
  2. Measure your per‑page build time locally, then compute build duration for each cutoff. Pick the knee of the curve.
  3. Implement generateStaticParams at that cutoff with dynamicParams: true.
  4. Build the revalidation endpoint with signature verification, batching, and CDN purge.
  5. Add jitter to Data Cache TTLs.
  6. Load‑test a stampede: expire a popular page's entry and fire 500 concurrent requests. Count origin renders. It should be 1, not 500.
  7. Measure TTFB and CDN hit ratio before/after.

Checklist

  • generateStaticParams covers the traffic knee, not the whole catalog
  • dynamicParams: true for 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

Next: 3.6 PPR & personalization