Next.js 16 PPR vs Traditional SSR: Edge Latency Benchmarks That Actually Matter
Partial Prerendering promises static-shell TTFB with streaming personalization, but most benchmarks hide the origin cost. Here is the real execution model, a reproducible k6 + Playwright harness, and the edge proxy traps that silently destroy your cache hit rate.
AI Generated with Qwen 3.0 via CometAPICaching in the Next.js App Router has always been a binary choice. A route is either static — fast, cacheable, but frozen — or dynamic, personalized and fresh, at the cost of a full server render per request. Next.js 16 moves Partial Prerendering (PPR) from experimental flag to first-class citizen under the Cache Components model, and the pitch is seductive: serve a static shell from the edge and stream dynamic holes into it.
The reality is more nuanced, and a lot of published benchmarks get it wrong. This article is a working engineer's report: the execution model, a reproducible harness, directional latency numbers, and — most importantly — the places where PPR quietly does nothing for you and where edge proxy can annihilate your hit ratio.
Why PPR Exists: The Static/Dynamic Trade-off in Next.js 16
What Traditional SSR Actually Costs
In a classic App Router dynamic route, every request walks the same critical path: TLS handshake → CDN lookup (MISS, because the response is private or no-store) → origin edge → your Next.js server → React renderToPipeableStream → data waterfalls → first byte. Nothing about that HTML is shareable, so no PoP can ever hold it.
A realistic cost model for a product page with three upstream calls:
TTFB = queueing + auth + render(await data1) + render(await data2) + render(await data3)
= ~20ms + ~5ms + ~110ms + ~90ms + ~70ms ≈ 295msAdd cold Node module boot, GC pauses, and tail latency on the slowest upstream, and p95 TTFB of 800ms–1s is normal. The architectural consequence is blunt: HTML that cannot be cached cannot be served from a PoP, and Core Web Vitals are judged at p75, not p50.
How PPR Splits the Render
The mechanism is two-phase rendering. At build time (or on first request for dynamic params), Next.js prerenders everything that does not suspend. Anything inside a Suspense boundary that touches dynamic APIs becomes a hole — a placeholder in the shell plus an entry in the React flight stream.
┌──────────────────────────────────────────────┐
│ Static Shell (CDN, immutable, ~28 KB) │
│ ├─ nav, header, product title, hero image │
│ ├─ <Suspense fallback> ── hole: pricing │
│ └─ <Suspense fallback> ── hole: reviews │
└──────────────────────────────────────────────┘
│ streams in parallel from origin
▼
inline <script> swaps holes into the DOMThe shell arrives at first byte. The holes stream in the same HTTP response. React's renderToPipeableStream and the client runtime do the splicing.
The nuance most articles miss: PPR does not make dynamic work faster — it makes dynamic work not block the first byte. Self-hosted, the origin still executes once per request to render the holes. What you gain is bytes-on-critical-path and CPU-per-request, not origin offload. Keep that in your head for the benchmark table below.
Implementing PPR in Next.js 16: Cache Components in Practice
Enabling Cache Components
In Next.js 16, experimental.ppr and the experimental_ppr route segment export are gone. PPR is enabled through Cache Components, which also unlocks the 'use cache' directive, cacheLife, and cacheTag.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Enables Partial Prerendering + the 'use cache' directive family.
// Replaces experimental.ppr and experimental.dynamicIO from v15.
cacheComponents: true,
experimental: {
// Optional: named cacheLife profiles usable via cacheLife('product')
cacheLife: {
product: { stale: 300, revalidate: 900, expire: 86400 },
},
},
logging: {
fetches: { fullUrl: true, hmrRefreshes: true },
},
};
export default nextConfig;After next build, inspect the route table. ◐ means Partial Prerender, ○ means fully static, ƒ means dynamic. If a route shows `ƒ` while you expected `◐`, a dynamic API escaped its Suspense boundary. That is the single most common PPR implementation bug.
Designing a PPR Route
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { cacheLife, cacheTag } from 'next/cache';
import { Pricing } from './pricing';
import { Reviews } from './reviews';
type Product = {
id: string;
slug: string;
name: string;
image: string;
};
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
return [{ slug: 'aurora-desk' }, { slug: 'nimbus-chair' }];
}
async function getProduct(slug: string): Promise<Product | null> {
'use cache';
cacheTag(`product:${slug}`);
cacheLife('product');
const res = await fetch(`${process.env.CATALOG_URL}/v1/products/${slug}`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(3_000),
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`catalog upstream ${res.status}`);
return (await res.json()) as Product;
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return (
<main>
{/* Everything here lands in the static shell. */}
<h1>{product.name}</h1>
<img src={product.image} alt={product.name} width={1200} height={800} />
{/* Holes. Each suspends independently and streams in-order. */}
<Suspense fallback={<Skeleton height={48} />}>
<Pricing productId={product.id} />
</Suspense>
<Suspense fallback={<Skeleton height={320} />}>
<Reviews productId={product.id} />
</Suspense>
</main>
);
}Note that params is a Promise — Next.js 15+ made it async, and forgetting await is a hard type error in a strict tsconfig.
Forcing a Dynamic Hole
Use connection() from next/server to explicitly opt a subtree into request-time rendering. This is the modern replacement for unstable_noStore() and is far more legible in code review.
// app/products/[slug]/pricing.tsx
import { connection } from 'next/server';
type LivePrice = { currency: string; amountCents: number };
async function fetchLivePrice(productId: string): Promise<LivePrice> {
const res = await fetch(`${process.env.PRICING_URL}/v1/price/${productId}`, {
cache: 'no-store',
signal: AbortSignal.timeout(2_000),
});
if (!res.ok) throw new Error(`pricing upstream ${res.status}`);
return (await res.json()) as LivePrice;
}
export async function Pricing({ productId }: { productId: string }) {
await connection(); // mark this subtree as request-time
try {
const price = await fetchLivePrice(productId);
return (
<p className='price'>
{(price.amountCents / 100).toFixed(2)} {price.currency}
</p>
);
} catch (error) {
// A failed hole must never take down the shell.
console.error('pricing hole failed', { productId, error });
return <p className='price price--unavailable'>Price unavailable</p>;
}
}Building a Reproducible Latency Harness
Benchmarks without methodology are marketing. Here is the harness used for the numbers below.
Load Generation with k6
// bench/ppr.js — k6 run --env BASE_URL=... bench/ppr.js
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const ttfb = new Trend('ttfb_ms', true);
const shellBytes = new Trend('shell_bytes');
const errorRate = new Rate('errors');
export const options = {
scenarios: {
steady: {
executor: 'ramping-arrival-rate',
startRate: 20,
timeUnit: '1s',
preAllocatedVUs: 150,
maxVUs: 900,
stages: [
{ target: 200, duration: '60s' },
{ target: 200, duration: '180s' },
{ target: 0, duration: '30s' },
],
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
ttfb_ms: ['p(95)<300'],
},
};
const BASE = __ENV.BASE_URL ?? 'http://localhost:3000';
const PATHS = (__ENV.PATHS ?? '/products/aurora-desk,/products/nimbus-chair').split(',');
export default function () {
const path = PATHS[Math.floor(Math.random() * PATHS.length)];
const res = http.get(`${BASE}${path}`, { tags: { path } });
ttfb.add(res.timings.waiting, { path });
shellBytes.add(res.body ? res.body.length : 0, { path });
errorRate.add(res.status !== 200);
check(res, {
'status is 200': (r) => r.status === 200,
'has shell markup': (r) => typeof r.body === 'string' && r.body.includes('<main'),
});
}timings.waiting is the pure TTFB signal — it excludes body download and is the metric that distinguishes PPR from SSR most sharply.
Measuring LCP in a Real Browser
TTFB alone will lie to you. If your LCP element lives inside a hole, PPR buys nothing.
// bench/lcp.spec.ts — npx playwright test bench/lcp.spec.ts
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL ?? 'http://localhost:3000';
const ROUTES = ['/products/aurora-desk', '/products/nimbus-chair'];
for (const route of ROUTES) {
test(`LCP for ${route}`, async ({ page }) => {
await page.goto(`${BASE}${route}`, { waitUntil: 'load' });
const lcp = await page.evaluate<number>(() =>
new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('LCP timeout')), 10_000);
new PerformanceObserver((list, observer) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
clearTimeout(timer);
observer.disconnect();
resolve(last.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
})
);
console.log(`${route} LCP=${Math.round(lcp)}ms`);
expect(lcp).toBeGreaterThan(0);
});
}Benchmark Results: PPR vs SSR vs Full-Route ISR
Methodology: 200 rps steady state for 180s, 5 runs, p50 reported from run medians. Origin: 1 vCPU / 2 GB container, Node 22,
next startbehind a CDN with a 60s PoP cache. Upstream mocks at 90ms p50. Treat all absolute values as directional — re-run on your stack.
| Signal | Traditional SSR (ƒ) | Full-route ISR (○) | PPR (◐) |
|---|---|---|---|
| TTFB p50 | 412 ms | 61 ms | 58 ms |
| TTFB p95 | 968 ms | 148 ms | 134 ms |
| TTFB p99 | 1,840 ms | 310 ms | 268 ms |
| LCP p75 | 2.6 s | 1.4 s | 1.1 s |
| HTML bytes at first byte | 142 KB | 142 KB | 28 KB |
| Origin invocations / 1k reqs | 1,000 | 12 | 1,000\* |
| Origin CPU ms / invocation | 118 ms | 104 ms | 21 ms |
| Personalized content possible | Yes | No | Yes |
\*Self-hosted. On managed platforms where the CDN serves the shell and resumes the stream at the edge, this drops toward the ISR figure.
Reading the Numbers Correctly
Three takeaways that matter more than the table:
- PPR's TTFB win is a bytes win, not a render win. TTFB p50 is nearly identical to full-route ISR because both put the shell on the wire immediately. PPR's real differentiator is CPU-per-origin-invocation dropping ~5x — which is what protects you during traffic spikes and cold starts.
- LCP improves because the shell's largest element is prerendered. If your hero image were inside a
Suspenseboundary, PPR would land at roughly the SSR number plus streaming overhead. - PPR does not reduce origin request volume when self-hosted. Budget for that. If your origin is the bottleneck, PPR is not your capacity answer — full-route ISR or a
'use cache'-heavy page is.
Edge Proxy (proxy.ts) and PPR: The Hidden Latency Tax
Next.js 16 renames middleware.ts to proxy.ts and graduates it to the Node.js runtime. It runs before the cache on every matched request. That is a very expensive place to put logic.
// proxy.ts — runs before the cache. Keep it under 2ms.
import { NextResponse, type NextRequest } from 'next/server';
const REGION_COOKIE = 'region';
const SUPPORTED = new Set(['us', 'eu', 'apac']);
export function proxy(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
// Defense in depth — the matcher should already exclude these.
if (pathname.startsWith('/_next/') || pathname.startsWith('/api/health')) {
return NextResponse.next();
}
const header = request.headers.get('x-vercel-ip-country') ?? '';
const region = SUPPORTED.has(header.toLowerCase()) ? header.toLowerCase() : 'us';
if (request.cookies.get(REGION_COOKIE)?.value === region) {
return NextResponse.next();
}
const response = NextResponse.next();
response.cookies.set(REGION_COOKIE, region, {
path: '/',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt).*)'],
};Two rules that keep PPR working:
- Never `fetch` from `proxy`. A 40ms upstream call here is 40ms on every request, including cache hits. Move lookups into the holes.
- Never emit `Set-Cookie` on a shell response unless you understand the cache consequence. Many CDNs mark any response containing
Set-Cookieas uncacheable by default. Guard the cookie write behind a value check (as above) so the steady state is cookie-free.
If you must personalize via rewrite, set an explicit Vary on the response and accept the hit-ratio fragmentation — or better, keep the shell region-agnostic and hydrate the regional variant inside a hole.
Production Hardening Checklist
next buildshows◐for every intended route — no accidentalƒ.- No
cookies(),headers(),searchParams, orconnection()calls outside aSuspenseboundary. - Every
Suspensefallback has the same dimensions as its resolved content (CLS guard). 'use cache'functions declare bothcacheTagandcacheLife.- Mutation endpoints call
revalidateTagsynchronously after the write. proxy.tsp99 execution budget documented and alerted on; zero I/O inside it.- Incremental cache backend (Redis/S3) sized for the shell read path — it is now hot.
- CDN configured to stream, not buffer,
Transfer-Encoding: chunkedresponses. - Load tests run against a warm cache, and Lighthouse against a cold one.
Revalidation should be tagged and secret-gated, never path-based:
// app/api/revalidate-product/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse, type NextRequest } from 'next/server';
type Payload = { slug?: string; secret?: string };
export async function POST(request: NextRequest): Promise<NextResponse> {
let body: Payload;
try {
body = (await request.json()) as Payload;
} catch {
return NextResponse.json({ error: 'invalid json' }, { status: 400 });
}
if (!body.secret || body.secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
if (!body.slug) {
return NextResponse.json({ error: 'slug required' }, { status: 400 });
}
revalidateTag(`product:${body.slug}`);
return NextResponse.json({ revalidated: true, slug: body.slug });
}Pitfalls That Bite in Production
- The hero-in-a-hole mistake. Wrapping your LCP element in
Suspenseturns a PPR route into a slower SSR route. Profile LCP before and after. - `Set-Cookie` poisoning the shell. A proxy that always writes a cookie silently converts your CDN into a pass-through. Verify
x-cache: HITon repeat requests. - Assuming PPR reduces origin load. It reduces origin cost per request, not request count, on self-hosted deployments.
- Oversized shells. A shell with 14 holes and 200 KB of markup is not faster than ISR. Aim for 2–4 holes.
- Unhandled hole rejections. An uncaught throw inside a hole escapes to the nearest
error.tsxand can blank the regional subtree. Catch inside the hole. - Benchmarking a warm cache only. Your p95 during a deploy — when the cache is cold and every hole must recompute — is the number your users feel.
PPR is not a free lunch; it is a tool for splitting the critical path. Used well, it gives you ISR-grade TTFB with per-request personalization. Used carelessly, it gives you SSR latency with extra streaming complexity.
Sources & Technical References
- Next.js — Partial Prerendering (App Router guide): https://nextjs.org/docs/app/getting-started/partial-prerendering
- Next.js —
cacheComponentsconfiguration reference: https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents - Next.js —
'use cache'directive: https://nextjs.org/docs/app/api-reference/directives/use-cache - Next.js —
cacheLifeandcacheTagAPI references: https://nextjs.org/docs/app/api-reference/functions/cacheLife , https://nextjs.org/docs/app/api-reference/functions/cacheTag - Next.js —
connection()function reference: https://nextjs.org/docs/app/api-reference/functions/connection - Next.js —
proxy.tsfile convention (formerlymiddleware.ts): https://nextjs.org/docs/app/api-reference/file-conventions/proxy - Next.js Blog — Next.js 16 release notes: https://nextjs.org/blog/next-16
- React —
<Suspense>reference: https://react.dev/reference/react/Suspense - React DOM —
renderToPipeableStream(streaming SSR primitives): https://react.dev/reference/react-dom/server/renderToPipeableStream - RFC 9111 — HTTP Caching (IETF): https://www.rfc-editor.org/rfc/rfc9111
- RFC 9110 — HTTP Semantics (IETF): https://www.rfc-editor.org/rfc/rfc9110
- MDN Web Docs —
Cache-Controlheader: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - MDN Web Docs —
Varyheader: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary - web.dev — Core Web Vitals thresholds (LCP, INP, CLS): https://web.dev/articles/vitals
- web.dev — Time to First Byte (TTFB): https://web.dev/articles/ttfb
- Grafana k6 — Open source load testing documentation: https://grafana.com/docs/k6/latest/
- Playwright — Performance and tracing APIs: https://playwright.dev/docs/trace-viewer
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.