“Should this page be static or dynamic?” is a question I answer on almost every Next.js project. It’s also the decision that most affects how fast a site feels and how much its hosting costs. The documentation explains each option well. What it can’t tell you is how big the differences are in practice, so I built a small test and measured them.

The test

I built two small Next.js 16.3 apps. Every page shows the same list of 20 items from a local data source that waits 300 ms before answering, about what a WordPress REST call or an unindexed database query might take.

The first app uses the classic caching model, the one most existing Next.js sites use:

  1. Static: no data at all, as a baseline.
  2. ISR: fetch with next: { revalidate: 60 }, so the page is built once and refreshed in the background at most once a minute.
  3. Dynamic, blocking: await connection() and an uncached fetch, so the page is rendered on every request.
  4. Dynamic, streamed: the same, but with the data inside a <Suspense> boundary.

The second app has Cache Components enabled (cacheComponents: true), the newer model in Next.js 16:

  1. use cache: the data function is cached with cacheLife("minutes").
  2. Static shell plus streamed data: the page layout is prerendered and only the data part runs on each request. Next.js calls this a Partial Prerender.

I ran production builds with next start on an Apple M1 and measured from the same machine, so the network is out of the picture. These numbers show the cost of the rendering strategy itself.

Result 1: time to first byte

StrategyTTFB medianComplete page median
Static (no data)1.1 ms1.1 ms
ISR, revalidate 601.0 ms1.1 ms
use cache (Cache Components)1.1 ms1.1 ms
Dynamic, blocking311 ms311 ms
Dynamic, streamed with Suspense5.2 ms310 ms
Static shell + streamed (Cache Components)3.0 ms315 ms
50 requests per page after warming up, measured locally.

Three things stand out:

  • Cached pages cost almost nothing. ISR and use cache were as fast as a page with no data. The 300 ms data source was out of the way, because the page had already been built.
  • A blocking dynamic page is as slow as its slowest query. Nothing reaches the browser until the 300 ms fetch finishes.
  • Streaming changes when content starts arriving, not when it’s finished. The streamed versions sent their first bytes in 3–5 ms, so the browser could start on the layout, fonts and CSS straight away. The complete page still took 310 ms, because the data still took 300 ms.

Result 2: throughput under load

Next I sent 1,000 requests to each page, 50 at a time.

StrategyRequests per secondMedian latencyp95 latency
ISR2,02519 ms44 ms
Dynamic, blocking154309 ms414 ms
Dynamic, streamed159306 ms354 ms
Static shell + streamed154311 ms409 ms
1,000 requests at a concurrency of 50 against a single next start process.

The cached page handled about 13 times as many requests per second. That’s the number that shows up on your hosting bill. Under load the dynamic page made 1,000 calls to the data source; the ISR page made at most one a minute.

Streaming didn’t improve throughput at all. The server still does the same work for every visitor. Streaming is about perceived speed, not capacity.

What each strategy looks like in code

ISR in the classic model is a single option on fetch:

export default async function Page() {
  const posts = await fetch("https://example.com/wp-json/wp/v2/posts", {
    next: { revalidate: 60 },
  }).then((r) => r.json());

  return <PostList posts={posts} />;
}

With Cache Components, the cache moves onto the function:

import { cacheLife } from "next/cache";

async function getPosts() {
  "use cache";
  cacheLife("minutes");
  return fetch("https://example.com/wp-json/wp/v2/posts").then((r) => r.json());
}

And a static shell with one fresh, personalised part:

import { Suspense } from "react";
import { connection } from "next/server";

async function Stock({ id }: { id: string }) {
  await connection(); // From here on, this runs on every request.
  const stock = await getLiveStock(id);
  return <p>{stock} left</p>;
}

export default function Product() {
  return (
    <>
      <ProductDetails /> {/* prerendered */}
      <Suspense fallback={<p>Checking stock…</p>}>
        <Stock id="123" />
      </Suspense>
    </>
  );
}

A mistake that’s easy to make

On a dynamic route such as /blog/[slug], leaving out generateStaticParams means the page renders on every request, even when all of its data is cached. I found exactly this on my own site: blog posts were coming back with cache-control: no-store. An empty generateStaticParams that returns [] fixed it. Each post now renders on its first visit and is cached after that.

Check your build output. In the route list, ○ means static, ◐ means partial prerender and ƒ means dynamic. Any ƒ you didn’t expect is worth a look.

How I choose

The page…Strategy
Is the same for everyone and changes occasionally (blog posts, product pages, landing pages)ISR or use cache
Is the same for everyone but has one live part (stock, prices, a “logged in as”)Static shell + streamed part
Is different for every visitor (dashboards, account pages, carts)Dynamic, with Suspense around the slow parts
Never changesStatic

My default is to cache everything that isn’t personal, then make the exceptions dynamic one component at a time. Starting dynamic and trying to add caching later is the slower path, and the throughput table shows the cost: about 155 requests a second instead of 2,000, from the same server.