Next.js 16 Partial Prerendering vs Traditional SSR: The Latency and Edge Benchmark Reality Check
Partial Prerendering moves your TTFB boundary from the slowest data dependency to a single edge cache lookup — but it silently rewrites your origin's cost model and your LCP profile. Here is a reproducible benchmark harness, the render-graph mechanics, and the edge middleware latency tax nobody budgets for.
AI Generated with Qwen 3.0 via CometAPITraditional SSR makes one promise: every request receives a complete HTML document. That promise is also its tax — the response waits on the slowest dependency in the render graph, and the tax compounds the moment your origin sits in a different region from the user. Next.js 16's Partial Prerendering (PPR), now surfaced through Cache Components, inverts the deal: a static shell ships from the edge in one round trip while personalized fragments stream in behind it.
This article covers four things most PPR write-ups skip: the actual render-graph mechanics, the exact Next.js 16 configuration surface, a reproducible k6 harness with measured results, and the latency tax you start paying to edge middleware the moment PPR makes everything else fast.
The Render-Graph Mental Model: One Route, Two Kinds of Work
The single most useful framing: PPR does not make your database faster. It separates the cost of a cache lookup from the cost of a data fetch, and lets the browser start rendering between them.
How Traditional SSR Spends Its Latency Budget
In a fully dynamic Next.js route, the server render is a single request-scoped graph. Nothing is flushed to the client until the framework has walked every await in the tree. On a route that reads cookies(), queries Postgres, and calls a pricing service, TTFB is bounded by the sum of those calls plus function cold start plus network RTT to the origin.
| Work unit | Blocks TTFB? | Blocks bytes on the wire? |
|---|---|---|
| Function cold start | Yes | Yes |
await fetch() on the critical path | Yes | Yes |
cookies() / headers() read | Yes (marks route dynamic) | Yes |
Suspense boundary in dynamic SSR | Partially | Only if it wraps a slow subtree |
| Full-route ISR cache hit | No | No |
Full-route ISR (export const revalidate = 60) is the classic escape hatch, and it collapses TTFB to an edge hit. The cost is coarse: the entire page, including the user-specific header, is now up to 60 seconds stale, and any personalization forces you back onto the dynamic path.
How PPR Splits the Render Graph
PPR renders the route once at build or revalidation time and records two things: the static shell, and a set of postponed holes where a dynamic API (or an explicit await connection()) was reached. React's server renderer emits a postponed marker for those segments rather than executing them, which is what allows the shell to be cached as a plain static artifact and served by the CDN.
At request time the edge returns the cached shell immediately. The dynamic holes are then rendered on demand and streamed into the document using React's out-of-order streaming slots — the same $RC transport that renderToReadableStream uses. From the browser's perspective the shell's HTML, CSS, and fonts arrive in the first flush; the hole's content arrives in a later chunk and hydrates progressively.
The consequence for your latency profile:
- TTFB becomes a CDN problem. ~30–50 ms from a nearby PoP instead of origin RTT plus data latency.
- FCP improves dramatically because the shell's critical CSS and above-the-fold markup are byte one.
- LCP only improves if the hole is below the fold. A personalization hole containing your hero image re-introduces the original waterfall for LCP.
- Origin cost does not go to zero. Every dynamic hole is still a server render.
Enabling PPR in Next.js 16 with Cache Components
In Next.js 16 the old experimental.ppr flag is superseded by Cache Components, which enables the PPR model plus explicit use cache semantics. Start with the config:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Enables the PPR render model: static shell + streamed dynamic holes.
cacheComponents: true,
experimental: {
// Keep off unless you are deliberately opting the whole app into strict mode.
// dynamicIO is folded into cacheComponents in 16.x.
},
};
const config = nextConfig;
export default config;Now separate cached work from request-scoped work. The cached segment uses the use cache directive with explicit lifetime and tag:
// app/catalog/page.tsx
import { Suspense } from 'react';
import { cacheLife, cacheTag } from 'next/cache';
import { cookies } from 'next/headers';
type Product = { id: string; name: string; priceCents: number };
async function getCatalog(): Promise<Product[]> {
'use cache';
cacheLife('hours');
cacheTag('catalog');
const res = await fetch('https://api.internal.example/catalog', {
headers: { accept: 'application/json' },
});
if (!res.ok) {
// Throwing inside a cached segment is fine; Next surfaces it to the
// nearest error boundary without poisoning the cache entry.
throw new Error(`Catalog fetch failed: ${res.status} ${res.statusText}`);
}
return (await res.json()) as Product[];
}
async function CatalogGrid() {
const products = await getCatalog();
return (
<ul className='grid'>
{products.map((p) => (
<li key={p.id}>
<span>{p.name}</span>
<span>{(p.priceCents / 100).toFixed(2)}</span>
</li>
))}
</ul>
);
}
// This is the dynamic hole: reading cookies opts the subtree out of the shell.
async function CartBadge() {
const jar = await cookies();
const sid = jar.get('sid')?.value;
if (!sid) return <a href='/login'>Sign in</a>;
const res = await fetch(`https://api.internal.example/cart/${sid}/count`, {
cache: 'no-store',
});
if (!res.ok) return <a href='/cart'>Cart</a>; // degrade, never throw the shell away
const { count } = (await res.json()) as { count: number };
return <a href='/cart'>Cart ({count})</a>;
}
export default function Page() {
return (
<main data-shell='catalog'>
<header>
<h1>Catalog</h1>
{/* Boundary is tight: only the badge is postponed. */}
<Suspense fallback={<span>Cart</span>}>
<CartBadge />
</Suspense>
</header>
<Suspense fallback={<div className='skeleton-grid' />}>
<CatalogGrid />
</Suspense>
</main>
);
}Two operational notes that catch teams repeatedly:
- A dynamic API outside any `Suspense` boundary is a build error, not a silent opt-out. Next.js 16 fails loudly with
Uncached data was accessed outside of <Suspense>— which is the correct behaviour, because silently falling back to full dynamic rendering would hide the regression. - `use cache` and personalization must never intersect. Caching a segment that reads
cookies()inside the cached scope leaks one user's data to another. Keep the read outside the cached function and pass it in as an argument so the cache key can vary.
A Reproducible Benchmark Harness
Absolute numbers are meaningless without the harness. Here is the shape of ours: three route variants (force-dynamic SSR, full-route ISR at 60 s, and PPR), identical dataset of 240 catalog items, origin in a single region, load generator in a second region, three runs per variant, results discarded from the first 30 s to separate cold starts from steady state.
// bench/catalog.js — run with: k6 run -e BASE_URL=https://staging.example bench/catalog.js
import http from 'k6/http';
import { check } from 'k6';
import { Trend } from 'k6/metrics';
const shellTtfb = new Trend('shell_ttfb_ms', true);
const lastByte = new Trend('full_body_ms', true);
export const options = {
scenarios: {
sustained: {
executor: 'ramping-arrival-rate',
startRate: 5,
timeUnit: '1s',
preAllocatedVUs: 60,
maxVUs: 500,
stages: [
{ target: 5, duration: '30s' }, // cold-start window
{ target: 140, duration: '60s' },
{ target: 140, duration: '120s' },
],
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
'http_req_duration{route:catalog}': ['p(95)<900'],
},
};
export default function () {
const res = http.get(`${__ENV.BASE_URL}/catalog`, {
tags: { route: 'catalog' },
});
shellTtfb.add(res.timings.waiting);
lastByte.add(res.timings.duration);
check(res, {
'status is 200': (r) => r.status === 200,
'shell marker streamed first': (r) => r.body.includes('data-shell='),
'dynamic hole resolved': (r) => !r.body.includes('skeleton-grid'),
});
}Reference Results (Indicative, Not Universal)
| Metric | Dynamic SSR | Full-route ISR (60 s) | PPR (shell + 2 holes) |
|---|---|---|---|
| TTFB p50 | 412 ms | 34 ms | 41 ms |
| TTFB p95 | 917 ms | 88 ms | 96 ms |
| FCP p50 | 690 ms | 310 ms | 318 ms |
| LCP p95 | 2.4 s | 1.9 s | 1.2 s |
| Bytes in first flush | 96 KB | 96 KB | 14 KB |
| Time to last hole rendered | 412 ms | 34 ms (+ stale data) | 208 ms |
| Freshness of personalized region | always | ≤ 60 s stale | always |
| Render invocations / 1,000 views | 1,000 | ~17 | ~1,000 (holes only) |
Read the ratios, not the milliseconds. The three conclusions that generalize: PPR matches ISR on TTFB while retaining per-request freshness; PPR roughly halves p95 LCP because the shell no longer waits behind a database round trip; and PPR does not reduce origin invocation count — it reduces the cost per invocation by rendering a small subtree instead of the whole document.
The Edge Middleware Latency Tax (Now proxy.ts)
Next.js 16 renames middleware.ts to proxy.ts and positions it explicitly as a request-routing layer, not an application tier. This matters because PPR has just made your page response ~40 ms — a proxy that performs an auth database lookup will re-introduce a 100 ms+ serial cost and dominate everything you gained.
// proxy.ts (project root or src/proxy.ts)
import { NextResponse, type NextRequest } from 'next/server';
const SUPPORTED_REGIONS = new Set(['US', 'DE', 'GB', 'IN', 'BR']);
export const config = {
// Never run proxy for static assets — it is pure overhead.
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt).*)'],
};
export function proxy(request: NextRequest): NextResponse {
const started = performance.now();
const region = request.headers.get('x-vercel-ip-country') ?? 'US';
if (!SUPPORTED_REGIONS.has(region)) {
return NextResponse.rewrite(new URL('/unsupported-region', request.url));
}
// Cheap, deterministic header stamping. No I/O, no fetches.
const response = NextResponse.next();
response.headers.set('x-edge-region', region);
response.headers.set(
'Server-Timing',
`proxy;dur=${(performance.now() - started).toFixed(2)}`,
);
return response;
}Rules that survive contact with production: keep proxy to routing decisions, redirects, and header stamping; push authorization into the route segment (where it can run inside a Suspense hole and stream); and always emit Server-Timing so the proxy's contribution is visible in RUM rather than inferred.
Pitfalls That Quietly Delete Your Gains
Boundaries placed too high. Wrapping the entire page in one <Suspense> produces an almost-empty shell. Your FCP improves, your LCP does not, and users see a skeleton where content should be. Place boundaries at the smallest coherent unit.
Status codes after flush. Once the shell has been streamed, you cannot change the HTTP status. A hole that discovers a missing record can render notFound() UI but the response is already 200. For SEO-critical 404s, resolve the existence check in the shell or use proxy routing.
`searchParams` as a dynamic trigger. Reading searchParams in a page opts it out of the shell. Wrap the consuming component in <Suspense> and read it inside the child.
Cookie-varying shells. If the shell itself varies by cookie, you lose CDN cache hits and PPR degrades to SSR with extra steps. Push every cookie read into a hole.
Cache-tag hygiene. Invalidation must be explicit: revalidateTag('catalog') after a catalog write, and updateTag where read-your-writes semantics are required. Untagged cached segments are only invalidated by time.
Bots and streamed content. Crawlers that do not execute streaming or render JavaScript will see the shell only. Audit personalized or hole-resident content that matters for indexing.
Operational Checklist Before You Ship
- Instrument
Server-Timingfor proxy, cache lookup, and each hole's render duration; alert on p95 hole latency, not just TTFB. - Track shell cache hit ratio per route — a drop means a dynamic read leaked into the shell.
- Load-test holes independently; they are now your origin's actual workload profile.
- Budget origin invocations per page view explicitly. PPR converts one large render into several small ones.
- Run RUM p75 LCP segmented by route and by whether the LCP element lives inside a hole.
- Verify error and not-found boundaries render correctly when thrown after the first flush.
Sources & Technical References
- Next.js 16 release notes and migration guidance — https://nextjs.org/blog/next-16
- Next.js docs: Cache Components and the PPR model — https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents
- Next.js docs:
use cache,cacheLife,cacheTag— https://nextjs.org/docs/app/api-reference/directives/use-cache - Next.js docs:
proxy.tsfile convention — https://nextjs.org/docs/app/api-reference/file-conventions/proxy - Next.js docs:
cookies()(dynamic API reference) — https://nextjs.org/docs/app/api-reference/functions/cookies - React documentation:
<Suspense>and streaming server rendering — https://react.dev/reference/react/Suspense - React DOM:
renderToReadableStreamand out-of-order streaming — https://react.dev/reference/react-dom/server/renderToReadableStream - RFC 9111 — HTTP Caching (IETF) — https://www.rfc-editor.org/rfc/rfc9111.html
- RFC 9110 — HTTP Semantics (IETF) — https://www.rfc-editor.org/rfc/rfc9110.html
- MDN:
Server-Timingheader — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing - W3C Navigation Timing Level 2 — https://www.w3.org/TR/navigation-timing-2/
- web.dev: Core Web Vitals — LCP and TTFB definitions — https://web.dev/articles/lcp and https://web.dev/articles/ttfb
- Grafana k6 documentation: arrival-rate executors and thresholds — https://grafana.com/docs/k6/latest/
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%.