All ArticlesNext.js•November 6, 2026•9 min read

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.

Next.js 16 PPR vs Traditional SSR: Latency and Edge BenchmarksAI Generated with Qwen 3.0 via CometAPI

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

code
request → edge middleware → Node render → [await DB 180ms] → first byte → stream

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

code
request → edge middleware → serve shell from build cache (≈0ms compute)
        → stream dynamic holes as they resolve

The 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

ts
// 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).

tsx
// 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.

ts
// 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:

StrategyTTFB p50TTFB p95LCP p75Req/s at p95 < 500 ms
Full SSR (force-dynamic)212 ms486 ms2.1 s~340
SSR + CDN (uncacheable)198 ms451 ms2.0 s~355
PPR (shell + 2 holes)27 ms74 ms0.9 s~1,180
SSG/ISR (no dynamic data)19 ms41 ms0.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.

ts
// 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. Set X-Accel-Buffering: no and verify with curl -N that 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/catch or error.tsx, otherwise one flaky upstream degrades the whole document.

Operational Checklist

  1. 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.
  2. Assert no cookies()/headers() calls appear above the first Suspense boundary.
  3. Verify incremental delivery with curl -N -H 'Accept: text/x-component' and eyeball arrival timestamps per chunk.
  4. Budget edge proxy at <5 ms p95; emit Server-Timing and alert on regressions.
  5. 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.
  6. Load test dark launches at 3× expected peak; PPR's throughput advantage is a concurrency advantage, not an infinite one.

Sources & Technical References

2 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles