All ArticlesNext.js•November 10, 2026•10 min read

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.

Next.js 16 Partial Prerendering vs Traditional SSR: The Latency and Edge Benchmark Reality CheckAI Generated with Qwen 3.0 via CometAPI

Traditional 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 unitBlocks TTFB?Blocks bytes on the wire?
Function cold startYesYes
await fetch() on the critical pathYesYes
cookies() / headers() readYes (marks route dynamic)Yes
Suspense boundary in dynamic SSRPartiallyOnly if it wraps a slow subtree
Full-route ISR cache hitNoNo

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:

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

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

  1. 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.
  2. `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.

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

MetricDynamic SSRFull-route ISR (60 s)PPR (shell + 2 holes)
TTFB p50412 ms34 ms41 ms
TTFB p95917 ms88 ms96 ms
FCP p50690 ms310 ms318 ms
LCP p952.4 s1.9 s1.2 s
Bytes in first flush96 KB96 KB14 KB
Time to last hole rendered412 ms34 ms (+ stale data)208 ms
Freshness of personalized regionalways≤ 60 s stalealways
Render invocations / 1,000 views1,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.

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

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles