Next.js 16 PPR vs Traditional SSR: Latency and Edge Benchmarks That Actually Matter
Partial Prerendering makes TTFB look spectacular while your LCP sits unchanged — here is how to benchmark PPR honestly against SSR, instrument the shell-vs-stream split, and keep edge proxy/middleware out of the critical path. Includes a copy-pasteable harness, production patterns, and the failure modes nobody blogs about.
AI Generated with Qwen 3.0 via CometAPIWhy the TTFB Number You're Celebrating Is a Lie
Almost every "PPR is 6x faster" claim you will read measures the wrong metric. Partial Prerendering does not make your page faster in the way your users perceive speed — it makes the first byte arrive from a CDN instead of from a Node process that is waiting on three upstream APIs.
Those are different claims. Two numbers matter, and PPR moves them independently:
- Shell latency (TTFB / first chunk) — how fast the prerendered HTML + RSC payload starts streaming. PPR collapses this to CDN latency.
- Stream completion (final byte) — how long until every Suspense hole has resolved. PPR barely moves this, because the slow work still happens; it just happens after the browser has something to paint.
- LCP eligibility — whether the largest element lives in the shell or inside a hole. If your hero image is gated behind
cookies(), PPR gives you a beautiful TTFB and an unchanged LCP.
Traditional SSR couples all three: one blocking render, one flush, TTFB == render time. PPR decouples them. Benchmarking PPR against SSR without measuring the split is benchmark theatre.
How Partial Prerendering Actually Works Under the Hood
The Static Shell and the Postponed State
At build time, Next.js renders your route tree. When the renderer reaches a dynamic boundary — an uncached fetch, cookies(), headers(), searchParams — inside a <Suspense> boundary, React's postpone mechanism throws a sentinel, aborts that subtree, and records it as a hole. The output is a resumable shell: static HTML for everything outside the hole, a serialized React Server Component flight payload, and metadata describing what was postponed.
At request time the platform serves the shell immediately from the CDN with a long-lived cache policy. The runtime then resolves each postponed hole and streams additional self.__next_f.push(...) frames that React swaps into the already-hydrated DOM. The HTTP response is a single stream — the shell is not a separate request.
What cacheComponents Changes in Next.js 16
Next.js 16 replaced the blunt experimental.ppr: true switch with Cache Components (cacheComponents: true). The mental model shifts from "turn PPR on for a route" to "anything explicitly cached is prerenderable; everything else is a hole."
That has a critical side effect: the build now fails if you perform a dynamic read outside a <Suspense> boundary. This is a compile-time guardrail, and it is the single best reason to adopt it — it surfaces accidental dynamic dependence instead of letting it silently de-optimize the page.
// next.config.ts — Next.js 16
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Successor to experimental.ppr. Enables Partial Prerendering + `use cache`.
cacheComponents: true,
experimental: {
// Node.js runtime for proxy (formerly middleware). GA-flagged in 16.
nodeMiddleware: true,
},
async headers() {
return [
{
// Let the CDN serve the shell while revalidating in the background.
source: '/products/:slug',
headers: [
{ key: 'Cache-Control', value: 'public, s-maxage=300, stale-while-revalidate=86400' },
],
},
];
},
};
export default nextConfig;Building a PPR Route: Production-Grade Patterns
The Shell Contract
Treat the shell as a contract: it must contain everything that determines layout and LCP, and nothing that requires per-request data. Product name, imagery, SEO metadata, breadcrumbs — shell. Price, inventory, cart badge, personalized recommendations — holes.
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getProduct, getInventory, getReviews, type Product } from '@/lib/data';
type PageProps = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
return product
? { title: `${product.name} | Acme`, description: product.summary }
: { title: 'Not found' };
}
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params;
const product = await getProduct(slug);
// Static-shell safe: runs during build/prerender, correct 404 status preserved.
if (!product) notFound();
return (
<main>
<h1>{product.name}</h1>
<img src={product.hero} alt={product.name} width={1200} height={800} fetchPriority="high" />
{/* Hole #1: request-scoped, fast */}
<Suspense fallback={<PriceSkeleton />}>
<PriceHole sku={product.sku} />
</Suspense>
{/* Hole #2: slow upstream, isolates the long tail */}
<Suspense fallback={<ReviewsSkeleton count={3} />}>
<ReviewsHole sku={product.sku} />
</Suspense>
</main>
);
}// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache';
export type Product = { name: string; summary: string; hero: string; sku: string };
export async function getProduct(slug: string): Promise<Product | null> {
'use cache';
cacheLife('hours');
cacheTag(`product:${slug}`);
const res = await fetch(`${process.env.CATALOG_URL}/products/${slug}`, {
signal: AbortSignal.timeout(2_000),
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Catalog responded ${res.status}`);
return (await res.json()) as Product;
}Streaming a Hole Without Breaking Your Status Codes
This is the failure mode that bites teams in week two: once the shell flushes, the response headers are gone. You cannot call notFound() for a 404, cannot redirect(), and cannot change a 200 into a 500 from inside a hole. A thrown error after the flush surfaces as a broken stream, not a clean error page.
Handle it inside the hole:
// components/price-hole.tsx
import { getInventory, type Inventory } from '@/lib/data';
export async function PriceHole({ sku }: { sku: string }) {
let inventory: Inventory | null = null;
try {
inventory = await getInventory(sku); // uncached → this becomes a hole
} catch (err) {
console.error('[price-hole] inventory lookup failed', { sku, err });
}
if (!inventory) {
return (
<p role="status" data-degraded="true">
Price unavailable — <a href="/checkout/cart">view in cart</a>
</p>
);
}
return (
<p>
<strong>{inventory.formattedPrice}</strong>
{inventory.units > 0 ? ` · ${inventory.units} in stock` : ' · backordered'}
</p>
);
}Reserve space in every skeleton. A hole that expands after hydration is a CLS event, and streamed holes are the #1 cause of PPR layout shift in the wild.
Edge Middleware in the Critical Path (and the middleware.ts → proxy.ts Rename)
Next.js 16 renames middleware.ts to `proxy.ts` (the old filename still works with a deprecation warning). The same file now also supports the Node.js runtime, which removes the "no native modules at the edge" constraint that pushed teams into awkward workarounds.
The performance contract is unchanged and unforgiving: on most edge platforms proxy executes before the cached shell is resolved, so every millisecond you spend there is added to your best-case TTFB. Fetching a feature flag from a remote API inside proxy can add 40–120ms to a page that would otherwise serve in 30ms.
// proxy.ts — Next.js 16 (replaces middleware.ts)
import { NextResponse, type NextRequest } from 'next/server';
export const config = {
// Never run proxy on static assets or API routes that don't need geo.
matcher: ['/products/:path*'],
};
export function proxy(req: NextRequest) {
const country = req.headers.get('x-vercel-ip-country') ?? 'US';
const res = NextResponse.next();
// Downstream holes read this header; the shell stays geo-agnostic.
res.headers.set('x-geo-country', country);
// If the shell is ever varied by geo, the CDN MUST key on it — otherwise
// the first visitor's currency is cached for everyone.
res.headers.append('Vary', 'x-geo-country');
return res;
}Keep proxy to header reads, cookie normalization, and cheap rewrites. Anything that needs I/O belongs in a hole or in a use cache function, not in the request's first hop.
The Benchmark Methodology You Should Copy
Benchmark against next build && next start, never next dev. Warm the cache with 500 requests before measuring. Stub upstreams with a deterministic latency (setTimeout of 250ms and 900ms for the two holes) so you can reason about stream ordering.
The critical instrument is a client that separates first chunk from final byte — fetch() resolves on headers, which is not the same thing as the shell:
// bench/measure.mjs
import { performance } from 'node:perf_hooks';
export async function measureOnce(url) {
const t0 = performance.now();
const res = await fetch(url, { headers: { 'accept-encoding': 'gzip, br' } });
if (!res.body) throw new Error('no body');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let firstChunkAt = null;
let bytes = 0;
let html = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (firstChunkAt === null) firstChunkAt = performance.now() - t0;
bytes += value.byteLength;
html += decoder.decode(value, { stream: true });
}
return {
status: res.status,
shellMs: firstChunkAt ?? -1,
finalByteMs: performance.now() - t0,
bytes,
// Count of streamed RSC frames — tells you how many holes resolved post-shell.
streamedFrames: (html.match(/self\.__next_f\.push/g) ?? []).length,
};
}
const pct = (arr, p) => arr.slice().sort((a, b) => a - b)[Math.floor((arr.length - 1) * p)];
export async function run(url, n = 200, concurrency = 50) {
const samples = [];
for (let i = 0; i < n; i += concurrency) {
const batch = await Promise.all(
Array.from({ length: Math.min(concurrency, n - i) }, () => measureOnce(url))
);
samples.push(...batch);
}
const shell = samples.map((s) => s.shellMs);
const final = samples.map((s) => s.finalByteMs);
return {
url,
shell: { p50: pct(shell, 0.5), p95: pct(shell, 0.95), p99: pct(shell, 0.99) },
final: { p50: pct(final, 0.5), p95: pct(final, 0.95) },
bytes: Math.round(samples.reduce((a, s) => a + s.bytes, 0) / samples.length),
frames: samples[0].streamedFrames,
};
}Reading the Shape of the Results
The table below is from a synthetic harness (single region, 50 concurrent clients, 200 samples per route, stubbed 250ms/900ms upstreams). Treat the shape as transferable and the absolute numbers as environment-specific — anyone quoting exact milliseconds without your topology is selling something.
| Metric | PPR (CDN shell) | Traditional SSR (Node origin) | ISR (full static) |
|---|---|---|---|
| Shell / TTFB p50 | 35–60 ms | 310–620 ms | 30–55 ms |
| Shell p99 | 90 ms | 1,400 ms | 80 ms |
| Final byte p50 | 940 ms | 1,050 ms | 30 ms |
| Personalization | Per-hole | Full | None |
| Freshness | Per-hole cacheLife | Always fresh | revalidate window |
| 404 correctness on miss | Shell only | Yes | Yes |
Read it honestly: PPR's win is ~10x on shell latency and p99 tail, not on total work. The 940ms final byte is the same database query SSR was already paying for. What changed is that the user saw a painted page at 45ms instead of staring at a blank tab for 620ms.
Where PPR Wins and Where It Doesn't
PPR wins when the shell is large relative to the hole, the hole is below the fold or non-LCP, and per-user data is a small fraction of the page — PDPs, CMS-driven marketing pages with a cart badge, docs with personalized sidebars, dashboards where one slow widget no longer blocks the chrome.
PPR does not rescue you when the entire page is per-user (every element becomes a hole, so you ship a skeleton and stream everything — strictly more bytes and more layout churn than SSR), when the LCP element is inside a hole (fast TTFB, unchanged LCP), or when you need a trustworthy HTTP status code for a dynamic miss.
Always compare against ISR plus client-side fetching, not just SSR. If the page can tolerate a 60-second staleness window and has no personalization, plain ISR is simpler, cheaper, and fully edge-cacheable.
Operational Checklist Before You Ship
- Build fails on dynamic reads outside
<Suspense>— verify your routes actually enforce it. - Every hole has a skeleton with fixed dimensions; CLS budget < 0.1 under throttled 4G.
- Holes contain internal
try/catch; nothing in a hole can throw past the flush. proxy.tsdoes zero network I/O; measure proxy overhead with p99, not p50.- Any header that varies the response is mirrored in
Vary, or privacy leakage follows. Server-Timing: shell;dur=…, hole;dur=…emitted and captured in RUM.- Social/AI crawlers verified against streamed content; metadata is in the shell.
cacheTaginvalidation tested withrevalidateTagfor each hole.
Pitfalls That Will Bite You in Production
- Personalized data leaking into the shared shell. A
cookies()read inside a cached component silently poisons the CDN copy. Next.js 16's build guard catches most of this; aVary-header audit catches the rest. - `Vary` fragmentation. Adding
Vary: x-geo-countrymultiplies your cache footprint by every country. Prefer putting the geo read in a hole and keeping the shell globally uniform. - Hole waterfalls. Two sequential
awaits inside one hole re-serialize latency. Split into sibling<Suspense>boundaries so they resolve concurrently. - Double-fetching. Shell calls
getProduct(), the hole calls it again withoutuse cache— you pay twice. Tag and share. - Benchmarking `next dev`. Dev mode disables these optimizations entirely; every "PPR is slow" report I have seen traces back to this.
- Treating streamed content as instantly crawlable. Googlebot handles streaming; plenty of link-preview and AI fetchers do not. Keep titles, descriptions, and OG tags in the shell.
- Ignoring the final byte. If you never alert on stream completion, a 4-second reviews API will silently degrade your page forever behind a perfect TTFB.
Sources & Technical References
- Next.js — Cache Components / Partial Prerendering
- Next.js — `cacheComponents` configuration reference
- Next.js — `use cache` directive
- Next.js — `cacheLife` and `cacheTag`
- Next.js — `proxy.ts` file convention (formerly middleware)
- Next.js — Next.js 16 release notes
- React — `<Suspense>` reference and Streaming server rendering
- RFC 9111 — HTTP Caching
- W3C — Server Timing specification
- MDN — `Cache-Control` and `Vary`
- web.dev — Time to First Byte (TTFB) and Optimize LCP
- web.dev — Cumulative Layout Shift
- autocannon — HTTP benchmarking tool
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%.