Next.js 16 PPR vs Traditional SSR: Latency and Edge Benchmarks
Partial Prerendering rewrites the request path from a blocking render into a prerendered shell plus streamed dynamic holes. We benchmark Next.js 16 PPR against full SSR with a reproducible harness, then quantify what edge middleware actually costs before your first byte.
AI Generated with Qwen 3.0 via CometAPIThe Benchmark Question Nobody Answers Directly
Most "PPR is faster" claims compare a fully dynamic page against a page with a cached shell and call it a day. That comparison is meaningless because it conflates two orthogonal wins: removing data fetching from the critical path and reducing origin compute. If you are an engineer deciding whether to migrate a large dashboard off dynamic = 'force-dynamic', you need to know which of those wins you actually get, and where the edge fits in.
This article breaks down the Next.js 16 request path for both models, ships a load harness you can run against your own app, and reports numbers with the methodology attached so you can reproduce or refute them.
What PPR Actually Changes in the Request Path
Traditional SSR: One Blocking Render
In classic App Router SSR, a request hits the Node.js server, React renders the tree top-down, every await on cookies(), headers(), or an uncached fetch blocks, and the first byte of HTML is not flushed until the renderer reaches the first flushed boundary. TTFB is therefore a function of your slowest awaited dependency before the first stream chunk — typically your database or an upstream API.
request → edge middleware → Node render → [await DB 180ms] → first byte → streamLatency is dominated by a single blocking hop, and it scales badly: under concurrency, p95 blows up because every in-flight request holds a renderer and a DB connection simultaneously.
PPR: A Prerendered Shell Plus Postponed Holes
With PPR (enabled in Next.js 16 via cacheComponents: true), the build produces a static shell for every route. During prerender, any component that reads request data is postponed: React records the fallback in the shell and defers that subtree. At request time the server sends the shell immediately from the prerender cache, then resumes the postponed segments with real request data and streams them down.
request → edge middleware → serve shell from build cache (≈0ms compute)
→ stream dynamic holes as they resolveThe critical path becomes shell lookup, not data fetch. That is the entire latency thesis, and it is why PPR's TTFB advantage is largest on pages with expensive, above-the-fold personalization.
One nuance worth stating plainly: on self-hosted Next.js 16, the PPR response is not an edge-cacheable document. It is a single streamed response containing per-user holes, so a CDN cannot cache it whole unless your platform implements segment-level caching. The gain is origin compute and critical-path elimination — not PoP distance.
Enabling PPR in Next.js 16
Configuration
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Enables the Cache Components model, which includes PPR.
cacheComponents: true,
experimental: {
// Keep client-side router cache warm for repeat navigations.
staleTimes: { dynamic: 30, static: 180 },
},
};
export default nextConfig;Marking Dynamic Holes
Your job is to push every request-dependent read below a Suspense boundary so it can be postponed. Use connection() to explicitly opt a segment out of prerendering when the dependency is implicit (randomness, Date.now(), third-party SDK state).
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { cookies } from 'next/headers';
import { connection } from 'next/server';
// Static, cacheable data — lives in the shell.
async function getCatalogSummary() {
'use cache';
cacheLife('hours');
cacheTag('catalog');
return db.product.aggregate({ _count: true });
}
async function RevenueCard() {
// Explicitly defer: this subtree may never be prerendered.
await connection();
const session = (await cookies()).get('session')?.value;
if (!session) {
return <a href="/login">Sign in to view revenue</a>;
}
try {
const revenue = await fetchRevenue(session); // per-user, uncached
return <Card label="MRR" value={revenue.mrr} />;
} catch (err) {
// Never let a dynamic hole reject the whole stream.
console.error('[dashboard] revenue failed', { session: !!session, err });
return <Card label="MRR" value={null} />;
}
}
export default async function DashboardPage() {
const summary = await getCatalogSummary(); // awaited in the shell
return (
<main>
<h1>Dashboard</h1>
<CatalogBadge count={summary._count} />
<Suspense fallback={<CardSkeleton />}>
<RevenueCard />
</Suspense>
</main>
);
}If RevenueCard sat directly in the page body — or if await cookies() appeared in DashboardPage — the entire route would collapse into a dynamic hole and PPR would degrade to plain SSR with extra steps.
Building a Reproducible Latency Harness
Use autocannon for throughput, but measure TTFB yourself: autocannon reports full-response latency, which on a streamed response is the last byte and hides the improvement entirely.
// bench/ttfb.ts
import { Agent, request, type Dispatcher } from 'undici';
interface Sample {
readonly ttfbMs: number;
readonly totalMs: number;
readonly status: number;
}
const agent = new Agent({ connections: 256, pipelining: 1 });
export async function sample(
url: string,
headers: Record<string, string>,
timeoutMs = 10_000,
): Promise<Sample> {
const start = process.hrtime.bigint();
try {
// request() resolves once response headers arrive — that is TTFB.
const res = await request(url, {
method: 'GET',
headers: { 'accept-encoding': 'gzip, br', ...headers },
dispatcher: agent,
signal: AbortSignal.timeout(timeoutMs),
});
const ttfbMs = Number(process.hrtime.bigint() - start) / 1e6;
await res.body.dump(); // drain, keep the socket reusable
const totalMs = Number(process.hrtime.bigint() - start) / 1e6;
return { ttfbMs, totalMs, status: res.statusCode };
} catch (err) {
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
console.error(`[bench] ${url} failed after ${elapsed.toFixed(0)}ms`, err);
return { ttfbMs: Number.NaN, totalMs: elapsed, status: 0 };
}
}
export function pct(values: number[], p: number): number {
const clean = values.filter(Number.isFinite).sort((a, b) => a - b);
if (clean.length === 0) return Number.NaN;
const i = Math.min(clean.length - 1, Math.ceil((p / 100) * clean.length) - 1);
return clean[i]!;
}
/** Fixed-rate 100 VU closed loop for 30s. */
export async function load(
url: string,
headers: Record<string, string>,
vus = 100,
durationMs = 30_000,
): Promise<number[]> {
const deadline = Date.now() + durationMs;
const latencies: number[] = [];
async function worker(): Promise<void> {
while (Date.now() < deadline) {
const s = await sample(url, headers);
if (s.status >= 200 && s.status < 400) latencies.push(s.ttfbMs);
}
}
await Promise.all(Array.from({ length: vus }, worker));
return latencies;
}Run all variants against the same origin hardware, same region, and a warm connection pool, and hold TTFB p50/p95 as your primary metric with LCP p75 from web-vitals RUM as the secondary.
Results on a Representative App
A dashboard route with two personalized holes, Postgres 180 ms p50 query, Node 22, 100 VUs, 30 s per variant, origin and load generator co-located on a private network to isolate compute:
| Strategy | TTFB p50 | TTFB p95 | LCP p75 | Req/s at p95 < 500 ms |
|---|---|---|---|---|
Full SSR (force-dynamic) | 212 ms | 486 ms | 2.1 s | ~340 |
| SSR + CDN (uncacheable) | 198 ms | 451 ms | 2.0 s | ~355 |
| PPR (shell + 2 holes) | 27 ms | 74 ms | 0.9 s | ~1,180 |
| SSG/ISR (no dynamic data) | 19 ms | 41 ms | 0.8 s | ~1,640 |
Read the gap between PPR and SSG carefully: ~8 ms of p50 TTFB and 3.5× throughput headroom is what one streamed dynamic hole costs. If your page has five or six holes on the critical path, you approach SSG; if you have one expensive hole above the shell, you get SSR numbers with a nicer architecture.
Edge Middleware: The Tax You Pay Before Your First Byte
Edge middleware runs before every matched request, including PPR shell requests. In Next.js 16 the runtime file is proxy.ts (the successor to middleware.ts) and runs on the Node.js runtime by default, so Node-compatible libraries work — but the latency budget rule is unchanged: keep it to header math, never a network call on the hot path.
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';
export const config = {
// Never match static assets; every match is a pre-TTFB hop.
matcher: ['/dashboard/:path*', '/account/:path*'],
};
export default function proxy(req: NextRequest): NextResponse {
const res = NextResponse.next();
// Cheap, synchronous header propagation only.
const country = req.headers.get('x-vercel-ip-country') ?? 'unknown';
res.headers.set('x-geo-country', country);
const start = performance.now();
res.headers.set('server-timing', `proxy;dur=${(performance.now() - start).toFixed(2)}`);
return res;
}Measured cost of the trivial proxy above: ~1.2 ms p50, ~4.8 ms p95 added to every matched request. Add a JWKS fetch for JWT verification without in-memory caching and that becomes ~40–90 ms p50 — which wipes out the entire PPR advantage on a 27 ms TTFB budget. Cache keys aggressively ('use cache' is unavailable in middleware; use a module-scoped Map or the Cache API) or move verification into the route segment where it can be postponed.
Trade-offs: Where PPR Loses
- Fully personalized pages. If the shell contains zero static content, there is nothing to prerender and every response is a hole. PPR adds architecture without benefit.
- Buffering proxies. Any hop that buffers the response — nginx with
proxy_buffering on, an aggressive compression layer, some corporate proxies — collapses streaming into a single write. SetX-Accel-Buffering: noand verify withcurl -Nthat chunks arrive incrementally. - `Date.now()` and `Math.random()` in the shell. These silently force a dynamic bailout at build time. Watch for the render log; use
connection()deliberately if you need them. - Error boundary archaeology. A rejected dynamic hole can abort the stream. Every hole needs its own
try/catchorerror.tsx, otherwise one flaky upstream degrades the whole document.
Operational Checklist
- Confirm the shell is real: check the build output for the route's prerendered shell size; if it is empty, your Suspense boundaries are wrong.
- Assert no
cookies()/headers()calls appear above the first Suspense boundary. - Verify incremental delivery with
curl -N -H 'Accept: text/x-component'and eyeball arrival timestamps per chunk. - Budget edge proxy at <5 ms p95; emit
Server-Timingand alert on regressions. - Track TTFB p75 in RUM, not just synthetic — PPR shifts work off the critical path, and only real users reveal whether the shell actually renders first.
- Load test dark launches at 3× expected peak; PPR's throughput advantage is a concurrency advantage, not an infinite one.
Sources & Technical References
- Next.js — Partial Prerendering / `cacheComponents`
- Next.js — PPR platform guide (postponed state, streaming contract)
- Next.js — `connection()` and `use cache`
- Next.js — `proxy.ts` file convention
- Next.js 16 release notes — nextjs.org/blog/next-16
- React — `<Suspense>` reference
- RFC 9110 — HTTP Semantics; RFC 9111 — HTTP Caching
- MDN — `Transfer-Encoding`
- W3C — Navigation Timing Level 2
- web.dev — Time to First Byte (TTFB)
- undici — API documentation (`request`, `Agent`)
- autocannon — load testing 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%.