All insights

LabOctober 19, 20266 min read

The page that never hit the cache: a Next.js lab you can run

Share

A marketing page that should have been cached was rendered on the server for every visitor. I rebuilt the failure in a lab with pinned versions, measured it, and applied the three fixes one at a time. Two of the three, on their own, change nothing.

A page that should be served from the cache, and isn't, looks fine. It loads. It renders. Nobody files a bug. The only sign is that every visitor waits for the CMS, every time, and the hosting bill scales with traffic instead of staying flat. I've watched this happen on a production site and I can't write about that one. So I rebuilt the failure in a lab you can run in ten minutes, with the version numbers pinned, and I want to show you the three fixes and why any one of them alone does nothing.

The setup

Next.js 15.5.4, App Router, one page that fetches its content from a headless CMS. The CMS is a 20-line Node server with 400 ms of artificial latency and a timestamp in the body, so a cache hit is visible to the eye: if the timestamp changes between two requests, the CMS was called again. A page also reads a consent cookie, which is the kind of harmless-looking line that causes the whole problem.

// cms.js: a stand-in for the headless CMS
const http = require('http'); let calls = 0;
http.createServer((req, res) => { calls++; setTimeout(() => {
  res.setHeader('content-type', 'application/json');
  res.end(JSON.stringify({ title: 'Store locator',
    generatedAt: new Date().toISOString(), cmsCalls: calls }));
}, 400); }).listen(4000);

Before: the page everyone ships first

// app/before/page.js
import { cookies } from 'next/headers';
export default async function Page() {
  const jar = await cookies();
  const consent = jar.get('consent')?.value ?? 'none';
  const res = await fetch('http://localhost:4000/page', { cache: 'no-store' });
  const data = await res.json();
  return <main><h1>{data.title}</h1>
    <p>CMS generated at {data.generatedAt}</p><p>consent: {consent}</p></main>;
}

Two things are wrong and each is enough on its own. The cookies() call tells Next.js the page depends on the request, so the route becomes dynamic and is rendered on the server for every visitor. The cache: 'no-store' on the fetch tells Next.js never to keep the CMS response, so every render pays the 400 ms again. The build output says so, if you know where to look: a ƒ next to the route means dynamic, a ○ means static.

Route (app)          Revalidate  Expire
├ ƒ /before
├ ƒ /half-a
├ ƒ /half-b
├ ○ /after                   5m      1y

The two half-fixes that look like fixes

This is the part that costs teams weeks. Someone removes the cookie read. Someone else, in a different ticket, adds a revalidate window to the fetch. Both changes get merged, both look right in review, and the page is still rendered on every request. I built both halves as separate routes so you can see each one fail.

Half A removes cookies() but keeps cache: 'no-store'. The route is still dynamic, and the timestamp still changes on every request. Nothing improved.

Half B keeps cookies() but caches the fetch with next: { revalidate: 300 }. The timestamp stops changing, because the data cache now holds the CMS response, and the server render drops from about 415 ms to about 12 ms. That feels like a win. But the route is still dynamic, and the response still carries Cache-Control: private, no-cache, no-store, which means no CDN or edge cache in front of it will ever store the page. Every visitor still reaches your server. You've moved the cost, not removed it.

After: all three, together

// app/after/page.js
export default async function Page() {
  const res = await fetch('http://localhost:4000/page', { next: { revalidate: 300 } });
  const data = await res.json();
  return <main><h1>{data.title}</h1>
    <p>CMS generated at {data.generatedAt}</p><ConsentBadge /></main>;
}
// The cookie-dependent part moves to the client and reads
// document.cookie after hydration. The server never sees it.
function ConsentBadge() {
  return <p id="consent" suppressHydrationWarning>consent: (read on the client)</p>;
}

Fix one: no request-time APIs (cookies(), headers(), searchParams) anywhere in the page tree. Fix two: the CMS fetch gets a revalidate window instead of no-store. Fix three: whatever needed the cookie moves to a client component, so the personalised bit happens in the browser and the HTML stays the same for everyone. Now the route is static with a five-minute revalidate, the response says s-maxage=300, stale-while-revalidate, and a CDN will serve it without touching your server.

What the numbers looked like

Five requests per route against the production build on one machine, measured with curl. The CMS counter is the number of times the mock CMS was called across the run.

RouteBuildResponse timeCache-ControlCMS called per request
/beforeƒ dynamic~415 msprivate, no-storeYes
/half-a (cookie removed)ƒ dynamic~415 msprivate, no-storeYes
/half-b (fetch cached)ƒ dynamic~12 msprivate, no-storeNo, but the server renders every time
/after (all three)○ static, 5m~4 mss-maxage=300, stale-while-revalidateNo

The response times are from a laptop-class container with no network in the way, so treat them as ratios rather than absolutes. The ratio is the point: the before page is a hundred times slower than the after page, and the half-fix that looks fastest still leaves the CDN out of the picture.

A cache miss is a product decision that nobody made. The cookie read was somebody's consent banner. The no-store was somebody's "make sure it's fresh." Each was reasonable. Together they turned a brochure page into a server request per visitor.

How to check your own site in two minutes

Run next build and read the route table. Any page that should be the same for everyone and shows ƒ is a candidate. Then request the live page with curl -D - -o /dev/null https://yoursite/page and read two headers. Cache-Control starting with private, no-cache, no-store means nothing in front of your server will keep it. On Vercel you'll also see x-vercel-cache, where DYNAMIC means every request reached a function and HIT means the edge served it. On a self-hosted build, x-nextjs-cache: HIT tells you the data cache or full route cache was used.

If a page you expected to be static shows dynamic, search the page and every layout above it for cookies(, headers(, searchParams, no-store and dynamic = 'force-dynamic'. One of them is there, often in a layout written months earlier, and often by a different team than the one writing the page.

Why a product manager should care

This isn't a performance nicety. The cost of a dynamic page is a function of traffic, and the cost of a cached page is close to zero past the first visitor. When a marketing page goes dynamic by accident, the hosting bill and the CMS bill both start tracking visits, and the page gets slower during exactly the campaigns that matter. The fix is three lines, but finding it needs someone who reads build output and response headers as product signals, not just engineering ones. That's the job I think a technical product manager is for.

Run it yourself

Six files, no accounts, no hosting. npm install next@15.5.4 react@19.1.1 react-dom@19.1.1, create the pages above under app/, start node cms.js on port 4000, then npx next build && npx next start. The CMS has to be running during the build because the static pages fetch at build time; that's the same reason a real CMS outage can break a deploy. Curl each route five times and watch the timestamp.

Sources and further reading