Piotr Czerwiński

Writing · June 12, 2026 · 9 min read

Static doesn't mean free: the Next.js prefetch storm that raised my CPU bill after moving to SSG

Next.js · performance · Vercel · debugging

TL;DR: After migrating my SEO pages to static generation (the story in The week my free tier ran out), serverless Active CPU went up, from seconds to minutes, with a handful of visitors. The cause was invisible in the code review: Next.js App Router <Link> prefetches every link that enters the viewport by default, each prefetch is a server request, and my pages were full of links. One visitor became 20 to 65 server invocations. The blunt fix, prefetch={false}, killed the storm but made every click feel slow, because in the App Router it disables hover prefetching too. The right answer turned out to be prefetch on intent: a short hover dwell timer plus immediate prefetch on touch. And a meta-lesson: my first fix targeted the wrong component, which I only learned because I measured instead of trusting the diff.

The graph that made no sense

I had just finished a satisfying migration: dynamic, per-request SEO pages became statically generated ones, served as prebaked HTML from the CDN. By every mental model I had, serverless compute should have dropped to near zero. Instead, the Active CPU meter accelerated: roughly ten seconds of CPU became roughly two minutes, and the logs attributed it to a session with about five visitors.

Five visitors. Two minutes of active CPU. On static pages. Something was multiplying.

The multiplier: default link prefetching

The App Router's <Link> component prefetches by default when a link scrolls into the viewport. For statically generated targets it fetches the full RSC payload of the target page, not just a route stub. This is a great default on a page with three links. It is a very different proposition on:

  • an SEO footer linking to a few hundred country × category combinations,
  • a landing page rendering a long list of cards, each card wrapping its content in a <Link> to a detail page,
  • a sitemap-style hub page whose entire purpose is links.

One visitor scrolling one page fired 20 to 30 RSC requests within a second. In the logs it looks like a crawler burst, except the user agent is a real browser. That is the signature: many different URLs, one second, one real-browser UA. That is prefetch.

Why the CDN didn't absorb it

"But the targets are static, surely the CDN serves the prefetches." That was my assumption, and it was wrong for one sneaky reason: prefetch requests carry an ?_rsc=<hash> query parameter, and the hash varies with client router state. My CDN treated each variant as a distinct cache key, so prefetches were cache misses that went straight to origin, where each one triggered a server render. Static generation saves money only when requests are actually served from a cache. Static does not mean free. It means cacheable, and cacheable only pays off when the cache is actually hit.

So the full equation was: bulk links × default prefetch × cache-miss RSC requests × a render that was itself heavy (the list pages prerender hundreds of records for client-side infinite scroll) = worse CPU than the old dynamic pages.

First fix, wrong target

I "knew" the culprit was the SEO footer with its hundreds of links, so I set prefetch={false} on those tiles and shipped. CPU barely moved. That failure was the most useful part of the whole incident, because it forced me to stop reading code and start counting requests.

Measured empirically (production build, headless browser, network log filtered to _rsc=), the dominant source was the job cards on list pages, not the footer. One <Link> per card times every card scrolled into view meant each visit to a list page prefetched dozens of detail pages, and a single detail page could get prefetched six times from different sections. Roughly 40 of the ~65 prefetch requests on the home page came from cards. Two more sources rounded it out:

  • Global chrome. The navbar and footer render on every page, so their handful of links re-prefetched on every page view. One of those targets was a deliberately uncached, data-heavy board page, so every page view anywhere re-rendered that board on the server.
  • target="_blank" links as <Link>. A link that opens a new tab gains nothing from client navigation, but <Link> still prefetches it. Those should just be plain <a> tags.

Two rules I keep from this: measure, don't pattern-match (the plausible culprit and the actual culprit were different components), and when verifying with browser automation, start from a fresh browser context, because a tab loaded before your rebuild will happily replay stale prefetch logs at you and convince you the fix does not work.

The overcorrection: prefetch={false} everywhere

With the real sources identified, I set prefetch={false} on cards, navbar, and footer. The storm died: a fresh page load produced zero prefetch requests, down from ~65. Victory lasted about a day, until real usage surfaced the cost: navigation started to feel laggy. Clicking a card meant waiting for the payload to download before anything happened.

The reason is an App Router behavior worth knowing: in the Pages Router, prefetch={false} still prefetched on hover. In the App Router it disables prefetching entirely, viewport and hover both. I verified by hovering a card for two seconds with the network log open: zero requests. So every click paid the full round trip.

Prefetch is a spectrum, not a switch. Default-on gives you a viewport storm. Fully off gives you slow clicks. The sweet spot is prefetching on demonstrated intent.

Prefetch on intent

The pattern that ended up in production: links keep prefetch={false} (so the viewport storm stays dead), and a small handler layer prefetches manually when the user signals intent:

const prefetched = useRef(false);
const dwellTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

const prefetchNow = () => {
  if (prefetched.current) return;
  prefetched.current = true;
  router.prefetch(href);
};

const onIntentStart = () => {
  if (prefetched.current) return;
  dwellTimer.current = setTimeout(prefetchNow, 120);
};

const onIntentCancel = () => {
  if (dwellTimer.current) {
    clearTimeout(dwellTimer.current);
    dwellTimer.current = null;
  }
};

// <Link prefetch={false}
//   onPointerEnter={onIntentStart}
//   onPointerLeave={onIntentCancel}
//   onTouchStart={prefetchNow}>

The three details that make it work:

  • A ~120 ms dwell on hover, cancelled on leave. This is the crucial one for lists: sweeping the mouse across twenty cards prefetches none of them, only the card you actually stop on. Without the dwell you have rebuilt a smaller storm out of mouse movement.
  • Immediate prefetch on touchStart. Mobile has no hover, but touchstart fires ~100 ms before click, which is enough head start to make the tap feel instant.
  • A once-guard per link. One prefetch per card per mount, no repeats.

The result: clicks feel as instant as with default prefetching, but the server sees a handful of prefetches per session (the cards the user actually aimed at) instead of every card that ever crossed the viewport. One honest caveat: a single router.prefetch() can fan out into several segment requests, so intent prefetching is cheap, not free. If it ever grows too costly, the knobs are raising the dwell threshold or narrowing the pattern to the most-clicked surfaces.

What I'd tell past me

  • Audit bulk links on day one. Any component that renders many <Link>s (footers, tile grids, card lists, sitemap pages) deserves an explicit prefetch decision, not the default.
  • SSG saves money only when requests hit a cache. RSC prefetch traffic can bypass your CDN's cache keys entirely. Check what ?_rsc= requests do in your setup before assuming the edge absorbs them.
  • One visitor is N requests. When a CPU or invocation graph looks impossible for your traffic, look for a per-visitor multiplier before doubting the traffic numbers.
  • Verify fixes empirically, in a fresh context. My first fix was a no-op for the dominant source, and only the request counter said so.
  • UX regressions are part of the cost function. The cheapest configuration (no prefetch at all) failed the product. Optimization is finished when both meters, cost and experience, are green.

The same underlying truth, one visitor equals N concurrent server renders, has a scarier cousin than cost: correctness. If a Server Component's render path ever performs a write, concurrent prefetch-triggered renders will race and duplicate it. But that is a story for another post.