“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:
- Static: no data at all, as a baseline.
- ISR:
fetchwithnext: { revalidate: 60 }, so the page is built once and refreshed in the background at most once a minute. - Dynamic, blocking:
await connection()and an uncached fetch, so the page is rendered on every request. - 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:
use cache: the data function is cached withcacheLife("minutes").- 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
| Strategy | TTFB median | Complete page median |
|---|---|---|
| Static (no data) | 1.1 ms | 1.1 ms |
| ISR, revalidate 60 | 1.0 ms | 1.1 ms |
use cache (Cache Components) | 1.1 ms | 1.1 ms |
| Dynamic, blocking | 311 ms | 311 ms |
| Dynamic, streamed with Suspense | 5.2 ms | 310 ms |
| Static shell + streamed (Cache Components) | 3.0 ms | 315 ms |
Three things stand out:
- Cached pages cost almost nothing. ISR and
use cachewere 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.
| Strategy | Requests per second | Median latency | p95 latency |
|---|---|---|---|
| ISR | 2,025 | 19 ms | 44 ms |
| Dynamic, blocking | 154 | 309 ms | 414 ms |
| Dynamic, streamed | 159 | 306 ms | 354 ms |
| Static shell + streamed | 154 | 311 ms | 409 ms |
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 changes | Static |
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.