All ArticlesNext.js•November 8, 2026•12 min read

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.

Next.js 16 PPR vs Traditional SSR: Edge Latency Benchmarks That Actually MatterAI Generated with Qwen 3.0 via CometAPI

Caching 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:

code
TTFB = queueing + auth + render(await data1) + render(await data2) + render(await data3)
     = ~20ms   + ~5ms +    ~110ms       +    ~90ms        +    ~70ms        ≈ 295ms

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

code
┌──────────────────────────────────────────────┐
│  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 DOM

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

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

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

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

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

ts
// 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 start behind a CDN with a 60s PoP cache. Upstream mocks at 90ms p50. Treat all absolute values as directional — re-run on your stack.

SignalTraditional SSR (ƒ)Full-route ISR (○)PPR (◐)
TTFB p50412 ms61 ms58 ms
TTFB p95968 ms148 ms134 ms
TTFB p991,840 ms310 ms268 ms
LCP p752.6 s1.4 s1.1 s
HTML bytes at first byte142 KB142 KB28 KB
Origin invocations / 1k reqs1,000121,000\*
Origin CPU ms / invocation118 ms104 ms21 ms
Personalized content possibleYesNoYes

\*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:

  1. 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.
  2. LCP improves because the shell's largest element is prerendered. If your hero image were inside a Suspense boundary, PPR would land at roughly the SSR number plus streaming overhead.
  3. 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.

ts
// 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-Cookie as 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 build shows ◐ for every intended route — no accidental ƒ.
  • No cookies(), headers(), searchParams, or connection() calls outside a Suspense boundary.
  • Every Suspense fallback has the same dimensions as its resolved content (CLS guard).
  • 'use cache' functions declare both cacheTag and cacheLife.
  • Mutation endpoints call revalidateTag synchronously after the write.
  • proxy.ts p99 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: chunked responses.
  • Load tests run against a warm cache, and Lighthouse against a cold one.

Revalidation should be tagged and secret-gated, never path-based:

ts
// 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 Suspense turns 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: HIT on 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.tsx and 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

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