Next.js

Link Prefetching at Scale: When It Hammers Your RSC Endpoint

A page with two hundred links prefetches two hundred routes. How next/link prefetching works, when it becomes a cost, and how to tune lists and mega-menus.

5 min read
Next.jsPerformanceCaching
962 words5 min read

What prefetching does

When a <Link> enters the viewport, Next.js fetches the target route's React Server Component payload in the background, so that when the visitor clicks, the navigation is instant. On a page with a dozen links this is one of the App Router's best features: navigation feels like a native app.

On a page with two hundred links, a sitemap-style directory, a large mega-menu, a product listing, a tag cloud, it is two hundred background requests to the server as the visitor scrolls, most of them for pages they will never click. On a busy site that shows up as server load, a spike in function invocations on a serverless platform, and a bill. It also competes for bandwidth with the images the visitor is actually looking at.

How it works in detail

In production, <Link> prefetches when it becomes visible (using an intersection observer) or on hover, depending on version and settings. What is fetched depends on the route:

  • For a static route (fully prerendered, or fully cached), the full RSC payload is prefetched.
  • For a dynamic route, only the layout down to the nearest loading.js boundary is prefetched; the dynamic part is fetched on click. This partial prefetch is much cheaper.
  • With Cache Components and Partial Prerendering, the static shell is prefetched and dynamic holes are filled on navigation.

Prefetched payloads are kept in the router cache for a period (short for dynamic, longer for static, configurable via staleTimes in next.config). Re-entering the viewport does not re-fetch within that window.

In development, prefetching is disabled, so the problem is invisible until production.

When it becomes a cost

Rough arithmetic: a listing page with 60 product links, each prefetching a static payload of 20 kilobytes, is 1.2 megabytes of background transfer per page view and 60 requests. On a site with 50,000 listing page views a month, that is 3 million prefetch requests, nearly all wasted. If those routes are dynamic and hit an origin function each time, the cost is real money and real latency for other visitors.

Signs you have this problem: the Network panel showing a flood of ?_rsc= requests on scroll; serverless invocation counts far higher than page views; an RSC endpoint at the top of the server's slow-request log; visitors on slow connections seeing images load late because prefetches got the bandwidth first.

The prefetch prop

<Link prefetch> accepts:

  • Default (unset). Prefetch on viewport entry, full payload for static routes, partial for dynamic. Right for navigation, headers, footers and the handful of links a visitor is likely to click.
  • prefetch={false}. No automatic prefetch; the route is fetched on click. Right for large lists, tag clouds, paginated results, and anything with dozens of links.
  • prefetch="auto" (16.x). The default behaviour, named.
  • prefetch="unstable_forceStale" and related experimental modes in recent versions let you prefetch the full payload of dynamic routes too, for the case where you know the visitor will click. Use sparingly.

Setting prefetch={false} on a list does not make navigation slow; it makes it normal, a fetch on click that takes a round trip. For a list where the visitor clicks one of sixty, that is the correct trade.

Hover prefetching as a middle ground

For lists where you want instant navigation on the item the visitor is considering, prefetch on hover or focus rather than on viewport entry. <Link prefetch={false}> plus router.prefetch(href) in an onMouseEnter handler, or a small wrapper component, gives instant clicks for the item under the cursor and nothing for the rest. On touch devices there is no hover, so the visitor gets a normal fetch on tap, which is fine.

Tuning the router cache

experimental.staleTimes in next.config sets how long prefetched payloads are considered fresh: dynamic (default 0 in recent versions) and static (default 5 minutes). Raising the static value reduces repeat prefetches for visitors moving back and forth between the same pages. Lowering it makes content fresher at the cost of more requests. For a business site, the defaults are reasonable; for a heavy directory, a longer static time helps.

Making the prefetched payload small

The cost of a prefetch is proportional to the payload. A route whose RSC payload is 150 kilobytes because the page component renders a large data table is expensive to prefetch and slow to navigate to regardless. Move the heavy part behind a Suspense boundary so the prefetch is the shell, or make the route dynamic so the prefetch is partial. Check payload sizes in the Network panel; anything over 50 kilobytes is worth looking at.

Mega-menus

A header menu with a hundred links renders on every page, so every page view prefetches a hundred routes when the menu opens (or on load, if the links are in the DOM). Set prefetch={false} on menu links, or prefetch on hover of the menu group rather than on viewport entry. For our own "who we build for" menu with categories and countries, the links are prefetch={false} and the top-level nav items prefetch normally.

A policy

  • Primary navigation, footer, calls to action, pagination controls: default prefetch.
  • Any list of more than about ten links: prefetch={false}, optionally hover prefetch.
  • Mega-menus and directories: prefetch={false}.
  • Anything linking to a heavy dynamic route: prefetch={false} and consider making the route lighter.

Then check the Network panel on the busiest pages in production and count the _rsc requests. Under ten on scroll is healthy.

Where this fits

Prefetch tuning is part of the performance pass on every Next.js site we deliver, because it is invisible in development and it is the difference between a listing page that is a joy to navigate and one that quietly costs the client money on every scroll.

All writingHire me for this