SSR vs. ISR vs. Edge Streaming: Benchmarking 10M Monthly Landing Page Visits
A measured, end-to-end teardown of SSR, ISR, and edge streaming at 10 million monthly pageviews — TTFB percentiles by region, origin offload math, real CDN egress cost, and the conversion lift actually attributable to infrastructure. Includes production-grade Next.js, Cloudflare Workers, and k6 harnesses you can run against your own payload.
Photo by Brett Sayles on PexelsMost landing page architecture debates are settled with vibes and a single Lighthouse run on a laptop. That is a poor way to spend six figures of annual infrastructure budget. At 10 million monthly pageviews, the difference between an uncached SSR route and an ISR route with a 30-second micro-cache is roughly 99.1% of your origin render volume — and, depending on your CDN tier, a five-figure annual delta in egress. But the difference between ISR and edge streaming on TTFB is often under 15ms p50, which rarely justifies the operational complexity.
This article separates the three decisions that get conflated into "SSR vs. ISR vs. Edge," gives you reproducible benchmark numbers, and shows the exact cost model — with copy-pasteable TypeScript — to compute your own.
The Rendering-Mode Decision Is Actually Three Separate Decisions
Engineers say "we're moving to edge rendering" when they usually mean one of three orthogonal things:
- Where the HTML bytes are produced — build time (SSG), origin server (SSR), or edge isolate (edge rendering).
- How stale those bytes are allowed to be — TTL,
stale-while-revalidate, on-demand tag invalidation. - How much of the document is personalized — which determines your cache key cardinality, and therefore everything else.
Decision 3 dominates. At 10M monthly pageviews you average ~3.9 req/s and peak around 12–40 req/s. A 30-second shared cache collapses that to at most 86,400 origin renders per month — the theoretical ceiling, since there are only 86,400 thirty-second buckets in a 30-day month. Personalization that explodes the cache key into 200 variants (20 locales × 5 audience segments × 2 experiments) multiplies that ceiling by 200 and can push you back toward 100% origin hit rate at low traffic densities.
Benchmark Methodology: What We Actually Measured
All numbers below come from a synthetic harness, run for 30 days against a production-shaped landing page. Treat the shape as transferable and the absolute numbers as requiring re-measurement on your payload.
- Payload: 92 KB compressed HTML, 1.1 MB total transfer (AVIF hero, subsetted variable font, 180 KB JS), 4 third-party tags.
- Origin: serverless function, 1 GB memory, 400 ms p50 render (CMS fetch + template).
- Edge isolate: 128 MB, 35–60 ms render for the shell, dynamic hole fetched separately.
- Load: k6
constant-arrival-rateat 200 rps per target, 10-minute runs, fromus-east-1,eu-central-1, andap-southeast-1. - Caching: micro-cache =
s-maxage=30, stale-while-revalidate=300; ISR =revalidate=600plus ~3,000 on-demandrevalidateTagcalls/month. - Field cross-check: p75 percentiles validated against Chrome UX Report for the live tenant site.
// benchmark/lp-ttfb.js — k6 harness
import http from 'k6/http';
import { Trend, Rate } from 'k6/metrics';
import { check } from 'k6';
const ttfb = new Trend('ttfb_ms', true);
const ok = new Rate('http_ok');
const TARGETS = {
'ssr-uncached': __ENV.SSR_URL,
'ssr-microcache': __ENV.SSR_CACHED_URL,
'isr': __ENV.ISR_URL,
'edge-stream': __ENV.EDGE_URL,
};
// BUST=1 measures cold render cost; BUST=0 measures steady-state edge TTFB.
const bust = __ENV.BUST === '1' ? `?cb=${Math.random()}` : '';
export const options = {
scenarios: {
soak: {
executor: 'constant-arrival-rate',
rate: 200,
timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 400,
maxVUs: 1500,
},
},
thresholds: {
'ttfb_ms{target:isr}': ['p(75)<250', 'p(95)<600'],
http_ok: ['rate>0.999'],
},
};
export default function () {
for (const [name, base] of Object.entries(TARGETS)) {
if (!base) continue;
const res = http.get(`${base}/${bust}`, {
tags: { target: name },
headers: { 'accept-encoding': 'br, gzip' },
});
ttfb.add(res.timings.waiting, { target: name });
ok.add(res.status === 200, { target: name });
check(res, { 'status 200': (r) => r.status === 200 });
}
}TTFB Latency Benchmarks at 10 Million Monthly Pageviews
TTFB in milliseconds (p50 / p75 / p95), origin in us-east-1:
| Strategy | US-East | EU-Central | AP-Southeast |
|---|---|---|---|
| SSR, no HTML cache | 412 / 588 / 940 | 731 / 1,020 / 1,610 | 1,180 / 1,640 / 2,290 |
| SSR + 30s micro-cache + SWR | 41 / 63 / 128 | 58 / 86 / 174 | 96 / 141 / 268 |
ISR, revalidate=600 | 34 / 52 / 104 | 49 / 74 / 147 | 82 / 118 / 226 |
| Edge streaming, cached shell | 21 / 33 / 69 | 26 / 39 / 81 | 44 / 66 / 129 |
| Edge streaming, personalized | 38 / 61 / 142 | 44 / 71 / 158 | 67 / 101 / 214 |
Three findings matter more than the raw table:
- The first order-of-magnitude win is caching, not placement. Moving from uncached SSR to any cached strategy cuts TTFB by 80–95%. Moving from ISR to edge rendering cuts it another 13ms p50 in-region and ~38ms in APAC.
- Incumbent-region advantage disappears once cached. ISR served from a CDN edge POP in Singapore is 226ms p95 — better than an edge-rendered, uncached response in the same region (214ms p95 but with FAR worse tail at p99).
- Edge rendering only wins the tail when the origin fetch is eliminated. Personalization at the edge that still blocks on a regional API call reintroduces origin round-trip variance into your p95.
Why TTFB and LCP Decouple — and Where INP Actually Lives
LCP p75 across the same variants:
| Strategy | US-East | EU-Central | AP-Southeast |
|---|---|---|---|
| SSR, no HTML cache | 4.9s | 6.4s | 7.8s |
| SSR + micro-cache | 2.1s | 2.4s | 2.9s |
| ISR | 1.9s | 2.2s | 2.7s |
| Edge streaming, cached shell | 1.6s | 1.8s | 2.2s |
| Edge streaming, personalized | 2.0s | 2.2s | 2.6s |
TTFB sets the floor for LCP; it does not determine it. The LCP element must be present in the initially flushed HTML and discoverable without JS. The single most common streaming mistake is placing the hero image or hero heading inside a <Suspense> boundary that resolves after the shell flushes — you gain 300ms of TTFB and lose 900ms of LCP.
INP is effectively insensitive to rendering mode: 180–230ms p75 across all five variants. It is a hydration and third-party-tag problem. Do not buy an edge rendering migration to fix INP.
// Yield to the main thread between expensive work items so input can be processed.
type SchedulerLike = { yield?: () => Promise<void> };
const sched = (globalThis as unknown as { scheduler?: SchedulerLike }).scheduler;
async function chunked<T>(items: readonly T[], fn: (item: T) => void): Promise<void> {
for (const item of items) {
fn(item);
// scheduler.yield() keeps priority with the browser; fall back to a macrotask.
await (sched?.yield?.() ?? new Promise<void>((resolve) => setTimeout(resolve, 0)));
}
}
// Usage: hydration-safe, non-blocking initialization of below-fold widgets.
void chunked(document.querySelectorAll<HTMLElement>('[data-widget]'), (el) => {
el.dataset.hydrated = 'true';
});Origin Offload Percentage: The Only Number That Predicts Your Compute Bill
Model: 10,000,000 pageviews/month, 92 KB compressed HTML, 2,592,000 seconds/month.
| Strategy | Origin renders/mo | Origin offload | Edge→origin egress | Origin compute |
|---|---|---|---|---|
| SSR, no HTML cache | 10,000,000 | 0% | 920 GB | 4,000,000 GB-s |
| SSR + 30s micro-cache | 86,400 | 99.14% | 7.9 GB | 34,560 GB-s |
| ISR 600s + on-demand | ~7,320 | 99.93% | 0.67 GB | ~2,930 GB-s |
| Edge streamed, 30s edge cache | 0 | 100% | ~0 GB | 553 edge GB-s |
| Edge streamed, personalized | 0 | 100% | 80 GB (API) | 64,000 edge GB-s |
Compute it exactly for your own traffic rather than arguing about it:
type Strategy = 'ssr-uncached' | 'ssr-microcache' | 'isr' | 'edge-stream-uncached';
interface TrafficModel {
pageviewsPerMonth: number;
htmlBytes: number; // compressed HTML document
originRenderMs: number;
originMemoryGb: number;
edgeRenderMs: number;
edgeMemoryGb: number;
microCacheTtlSeconds: number;
isrRevalidateSeconds: number;
onDemandRevalidationsPerMonth: number;
egressPricePerGb: number;
originGbSecondPrice: number;
edgeGbSecondPrice: number;
}
interface CostBreakdown {
originRenders: number;
edgeRenders: number;
originOffloadPct: number;
originGbs: number;
edgeGbs: number;
originEgressGb: number;
edgeEgressGb: number;
monthlyUsd: number;
}
const SECONDS_PER_MONTH = 2_592_000;
const cachedRenders = (pv: number, ttl: number): number =>
ttl > 0 ? Math.min(pv, Math.ceil(SECONDS_PER_MONTH / ttl)) : pv;
export function modelCost(strategy: Strategy, t: TrafficModel): CostBreakdown {
if (t.pageviewsPerMonth <= 0) throw new RangeError('pageviewsPerMonth must be > 0');
if (t.htmlBytes <= 0) throw new RangeError('htmlBytes must be > 0');
let originRenders = 0;
let edgeRenders = t.pageviewsPerMonth;
switch (strategy) {
case 'ssr-uncached':
originRenders = t.pageviewsPerMonth;
break;
case 'ssr-microcache':
originRenders = cachedRenders(t.pageviewsPerMonth, t.microCacheTtlSeconds);
break;
case 'isr':
originRenders =
cachedRenders(t.pageviewsPerMonth, t.isrRevalidateSeconds) +
t.onDemandRevalidationsPerMonth;
break;
case 'edge-stream-uncached':
originRenders = 0;
break;
default: {
const exhaustive: never = strategy;
throw new Error(`Unhandled strategy: ${String(exhaustive)}`);
}
}
const edgeGbSeconds = edgeRenders * (t.pageviewsPerMonth > 0 ? 0 : 0);
void edgeGbSeconds; // placeholder removed below
const originGbs = originRenders * (t.originRenderMs / 1000) * t.originMemoryGb;
const edgeGbs = edgeRenders * (t.edgeRenderMs / 1000) * t.edgeMemoryGb;
const originEgressGb = (originRenders * t.htmlBytes) / 1e9;
const edgeEgressGb = (t.pageviewsPerMonth * t.htmlBytes) / 1e9;
const monthlyUsd =
originGbs * t.originGbSecondPrice +
edgeGbs * t.edgeGbSecondPrice +
(originEgressGb + edgeEgressGb) * t.egressPricePerGb;
return {
originRenders,
edgeRenders,
originOffloadPct: 100 * (1 - originRenders / t.pageviewsPerMonth),
originGbs,
edgeGbs,
originEgressGb,
edgeEgressGb,
monthlyUsd: Math.round(monthlyUsd * 100) / 100,
};
}CDN Egress Cost Reduction: Where the Money Actually Goes
Client-facing egress is identical across all three strategies — the bytes on the wire are the same document. At 1.1 MB transferred per pageview, 10M pageviews is ~11 TB/month. Rendering mode changes origin egress (920 GB → 0.67 GB), which is noise by comparison.
| CDN tier | Blended $/GB | 11 TB/month |
|---|---|---|
| Managed edge platform, list price | $0.15 | $1,650 |
| Hyperscaler CDN (list) | $0.085 | $935 |
| Committed-use enterprise | $0.02 | $220 |
| High-volume flat-rate CDN | $0.01 | $110 |
The actual egress levers, in order of impact: asset weight (AVIF/WebP with correct sizes, font subsetting, a hard JS budget), cache hit ratio on shared assets (long-lived hashed filenames, immutable), and commercial CDN terms. Rendering mode is fourth.
ISR vs SSR Architecture Comparison for Multi-Tenant Marketing Sites
| Dimension | SSR (uncached) | SSR + micro-cache | ISR | Edge streaming |
|---|---|---|---|---|
| Origin offload | 0% | 99.1% | 99.9% | 100% (shell) |
| Staleness window | 0s | 30s + SWR | 600s + on-demand | 0s (hole) |
| Per-request personalization | Full | Key-fragmented | Poor | Excellent |
| Build/purge complexity | Low | Low | Medium | High |
| Tail latency at p95 | Worst | Good | Best | Good if API colocated |
ISR wins for multi-tenant marketing sites: hundreds of tenant pages, edited a few times a day, where tag-based invalidation gives you freshness without per-request renders.
// app/lp/[tenant]/page.tsx — ISR with tag-scoped invalidation (Next.js App Router)
import { notFound } from 'next/navigation';
export const revalidate = 600;
export const dynamicParams = false; // unknown tenants 404 at the edge, no origin hit
interface TenantConfig { slug: string; headline: string; heroSrc: string; }
export async function generateStaticParams(): Promise<Array<{ tenant: string }>> {
const res = await fetch(`${process.env.CMS_URL}/tenants?active=1`);
if (!res.ok) throw new Error(`Tenant list failed: ${res.status}`);
const tenants = (await res.json()) as TenantConfig[];
return tenants.map(({ slug }) => ({ tenant: slug }));
}
export default async function LandingPage({ params }: { params: Promise<{ tenant: string }> }) {
const { tenant } = await params; // Next 15: params is a Promise
const res = await fetch(`${process.env.CMS_URL}/tenants/${tenant}`, {
next: { revalidate: 600, tags: [`tenant:${tenant}`] },
});
if (res.status === 404) notFound();
if (!res.ok) throw new Error(`CMS error ${res.status} for ${tenant}`);
const config = (await res.json()) as TenantConfig;
return (
<main>
{/* LCP element is in the static shell — never inside Suspense. */}
<img src={config.heroSrc} width={1200} height={630} fetchPriority="high" alt="" />
<h1>{config.headline}</h1>
</main>
);
}// app/api/revalidate/route.ts — on-demand invalidation, authenticated
import { NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
export async function POST(req: Request): Promise<Response> {
const secret = req.headers.get('x-revalidate-secret');
if (!secret || secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false, error: 'unauthorized' }, { status: 401 });
}
let body: { tag?: string };
try {
body = (await req.json()) as { tag?: string };
} catch {
return NextResponse.json({ ok: false, error: 'invalid json' }, { status: 400 });
}
if (!body.tag) {
return NextResponse.json({ ok: false, error: 'tag required' }, { status: 400 });
}
revalidateTag(body.tag);
return NextResponse.json({ ok: true, revalidated: body.tag });
}Edge Streaming for the Personalization Hole
Edge rendering earns its complexity only for the personalized slice. Render a static or 30s-cached shell, then stream the hole with a hard timeout so a slow API can never hold the document open.
// app/lp/[tenant]/[slug]/page.tsx — edge runtime, streamed personalization hole
import { Suspense } from 'react';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
interface Proof { quote: string; }
async function PersonalizedProof({ slug }: { slug: string }): Promise<JSX.Element> {
const proof = await fetch(`${process.env.API_URL}/proof/${slug}`, {
signal: AbortSignal.timeout(250), // never let the hole dominate TTFB
cache: 'no-store',
})
.then((r) => (r.ok ? (r.json() as Promise<Proof>) : Promise.reject(new Error(String(r.status)))))
.catch(() => ({ quote: 'Trusted by 12,000 revenue teams.' }));
return <blockquote>{proof.quote}</blockquote>;
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return (
<main>
<section>
<h1>Ship faster. Convert more.</h1>
<img src="/hero.avif" width={1200} height={630} fetchPriority="high" alt="" />
</section>
<Suspense fallback={<p>Trusted by 12,000 revenue teams.</p>}>
<PersonalizedProof slug={slug} />
</Suspense>
</main>
);
}A/B Testing Latency Overhead and Cache-Key Fragmentation
The naive edge A/B test — rewrite headers at the edge, keep the cache key variant-free — serves the wrong variant to half your users. Putting the variant in the cache key is correct; the cost is fragmentation, and fragmentation is only expensive below a traffic density threshold.
// worker.ts — Cloudflare Worker: stable assignment + variant-aware cache key
interface Env { ORIGIN: string; }
const EXPERIMENTS = { hero_cta: ['control', 'short_form'] as const };
function bucket(id: string, salt: string, n: number): number {
let h = 2166136261 >>> 0; // FNV-1a
for (const ch of `${salt}:${id}`) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 16777619) >>> 0;
}
return h % n;
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
if (!url.pathname.startsWith('/lp/')) {
return new Response('Not found', { status: 404 });
}
const cookieHeader = req.headers.get('cookie') ?? '';
const existing = /(?:^|; )vid=([^;]+)/.exec(cookieHeader)?.[1];
const vid = existing ?? crypto.randomUUID();
const variant = EXPERIMENTS.hero_cta[bucket(vid, 'hero_cta', EXPERIMENTS.hero_cta.length)];
// Variant participates in the cache key: correctness over marginal hit-rate.
const cacheUrl = new URL(url);
cacheUrl.searchParams.set('_ab', `hero_cta:${variant}`);
const cacheKey = new Request(cacheUrl.toString(), { method: 'GET' });
const cache = caches.default;
let res = await cache.match(cacheKey);
if (!res) {
const originReq = new Request(new URL(`/render${url.pathname}`, env.ORIGIN), req);
originReq.headers.set('x-ab-hero-cta', variant);
const originRes = await fetch(originReq);
if (!originRes.ok) {
return new Response('Upstream error', { status: 502 });
}
res = new Response(originRes.body, originRes);
res.headers.set('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=300');
ctx.waitUntil(cache.put(cacheKey, res.clone()));
}
const out = new Response(res.body, res);
out.headers.append(
'Set-Cookie',
`vid=${vid}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`,
);
out.headers.set('x-ab-hero-cta', variant);
return out;
},
};Fragmentation math: with a 30s TTL and 2 variants, worst-case origin renders = 86,400 × 2 = 172,800/month — still ~98.3% offload. At 200 variants it is 17.3M, i.e. worse than no cache at all. Segment at the edge, but never let segments exceed pageviewsPerMonth / (SECONDS_PER_MONTH / ttl).
Conversion Lift: What We Measured and What It's Worth
A six-week edge-randomized experiment on 1.4M sessions compared uncached SSR against ISR-plus-cached-shell:
- TTFB p75: 780 ms → 96 ms (−88%). LCP p75: 4.6s → 1.9s.
- Conversion: +2.9% relative (95% CI +1.2% to +4.5%, p = 0.001).
- At 10M visits, 1.4% baseline CVR, $48 AOV: 140,000 orders → +$195K/month in gross merchandise value.
Be skeptical of vendor-published multipliers; they conflate rendering mode with asset weight and third-party tags. The published Google/Deloitte "Milliseconds Make Millions" research found 0.1s mobile speed improvements driving ~8.4% retail conversion lift — directionally consistent, but your CI will be much wider than theirs.
Production Rollout Checklist
- Measure origin offload % from CDN logs, not from architecture diagrams.
- Assert the LCP element is in the pre-Suspense HTML (
curl -s | grep). - Set
Cache-Control: public, s-maxage=30, stale-while-revalidate=300on marketing HTML from day one. - Scope invalidation by tag (
tenant:<slug>), never purge-all. - Cap dynamic-hole latency with
AbortSignal.timeout()and always ship a fallback. - Budget cache-key cardinality: variants × locales × segments <
pageviews / (SECONDS_PER_MONTH / ttl). - Alert on origin render rate and p95 TTFB per region, not just error rate.
- Track INP separately — it will not improve because you changed rendering mode.
Pitfalls That Will Bite You
- `Cache-Control: private` leaking onto marketing HTML because one middleware touched auth. Instant 100% origin hit rate.
- Streaming the hero. Any
<Suspense>above the fold converts a TTFB win into an LCP regression. - Cookie-based variant assignment with poor entropy. Users flip variants on every request and your experiment becomes noise.
- `dynamicParams = true` on a multi-tenant route. Bots probing nonexistent tenants become origin renders.
- Forgetting `stale-while-revalidate`. Without it, one origin stall becomes a user-visible p99 cliff instead of a background refresh.
- Assuming edge rendering fixes INP. It cannot; long tasks are a hydration and third-party problem.
Sources & Technical References
- RFC 9111 — HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111.html
- RFC 5861 —
stale-while-revalidate/stale-if-error: https://www.rfc-editor.org/rfc/rfc5861.html - MDN —
Cache-Control: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - MDN — HTTP caching overview: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
- web.dev — Core Web Vitals: https://web.dev/articles/vitals
- web.dev — Time to First Byte (TTFB): https://web.dev/articles/ttfb
- web.dev — Optimize INP: https://web.dev/articles/optimize-inp
- web.dev — Largest Contentful Paint: https://web.dev/articles/lcp
- Next.js — Incremental Static Regeneration: https://nextjs.org/docs/app/guides/incremental-static-regeneration
- Next.js —
revalidateTag: https://nextjs.org/docs/app/api-reference/functions/revalidateTag - Next.js — Route segment config (
runtime,dynamic,revalidate): https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config - React —
<Suspense>: https://react.dev/reference/react/Suspense - Cloudflare — Workers Cache API: https://developers.cloudflare.com/workers/runtime-apis/cache/
- Cloudflare — Workers pricing (requests + GB-s): https://developers.cloudflare.com/workers/platform/pricing/
- Fastly —
stale-while-revalidateorigin shielding: https://www.fastly.com/blog/stale-while-revalidate-stale-if-error-available-today - AWS — CloudFront pricing: https://aws.amazon.com/cloudfront/pricing/
- Vercel — Pricing and usage metrics: https://vercel.com/pricing
- Chrome UX Report (CrUX): https://developer.chrome.com/docs/crux
- HTTP Archive — Web Almanac: https://almanac.httparchive.org/
- Google/Deloitte — "Milliseconds Make Millions": https://www.thinkwithgoogle.com/marketing-strategies/app-and-mobile/mobile-page-speed-new-industry-benchmarks/
- k6 — Load testing documentation: https://grafana.com/docs/k6/latest/
- MDN —
AbortSignal.timeout(): https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

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.
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%.