Next.js 16 Link Prefetch Hammering Your RSC Endpoint

Next.js 16 Link prefetch flooding your RSC endpoint on hover? Here is why the new heuristic fires too eagerly and the prefetch={false} fix that holds.
Next.jsReactPerformance
June 14, 20266 min read1102 words

The Problem

Pushed a Next.js 16.3 build for a client e-commerce site on Tuesday. By Wednesday morning their Vercel function invocations dashboard was spiking to 8x normal traffic with no corresponding spike in real users. The function logs were full of ?_rsc= requests against the product listing route, hundreds per minute per visitor. The same routes the user never actually clicked.

The site has a megamenu with about 60 category links. Hovering anywhere near the menu was triggering an RSC fetch for every link inside it. On desktop with a trackpad, a single navigation gesture across the menu could fire 30+ background requests in under two seconds. The Vercel bill for the month was tracking 4x the previous month.

Network tab told the same story. Hover over the menu, watch a flood of GETs to /category/shoes?_rsc=1k2m, /category/bags?_rsc=1k2m, on and on. Most never resolved to a navigation. The user moved the mouse and the prefetched payloads got dropped on the floor.

Why It Happens

Next.js 16 changed the default prefetch behaviour for <Link>. In 15 and earlier, prefetch={true} only ran on viewport intersection, so the link had to actually scroll into view before the prefetch fired. In 16 the default switched to a "hover or focus" trigger, intended to make navigations feel instant on routes that were not yet in view. The viewport prefetch still happens, the hover prefetch is additive.

For most apps that is fine. For an app with a large nav, a dense product grid, or any UI where the cursor naturally sweeps across many links, the new heuristic is a denial-of-service on your own RSC endpoint. Each Link in the rendered tree registers a pointerenter handler. Every pointerenter fires a prefetch unless the route is already in the router cache. The router cache key includes search params, so any link with a unique query string is a cache miss every time.

The change is documented in the 16.0 release notes under "Improved navigation responsiveness", but the breaking impact is in a sentence near the bottom. The default is now prefetch="auto" and the heuristic for auto is the aggressive one. If you wrote <Link href="..."> without specifying the prop, you opted in.

There is also a Server Components Hot Reload bug in 16.0 through 16.2 where the hover prefetch ignores Cache-Control on the response and re-fetches every time, but that one is fixed in 16.3. The hover storm is by design.

The Fix

Three layers. Cap the per-link prefetch, then set sensible defaults at the wrapper level, then add a route-level kill switch for the cases where prefetch is actively harmful.

Layer 1: Opt menus and dense grids out of hover prefetch. Anywhere you have more than five links in a small area, pass prefetch={false} explicitly. This kills both the viewport and hover prefetch for that link, which is what you want for nav items the user might not visit:

// app/components/megamenu.tsx
import Link from 'next/link'

export function Megamenu({ categories }: { categories: Category[] }) {
  return (
    <nav>
      {categories.map((c) => (
        <Link key={c.slug} href={`/category/${c.slug}`} prefetch={false}>
          {c.name}
        </Link>
      ))}
    </nav>
  )
}

The trade-off is the first navigation feels slightly slower. In practice the RSC payload for a category page comes down in 200ms on a warm edge, so the user-perceived delta is barely noticeable. The bill delta is enormous.

Layer 2: Use the viewport-only mode for the rest. For grids and feeds where you do want some prefetch but not on hover, set prefetch="render" (the old 15.x behaviour). That fires prefetch only when the link enters the viewport and never on pointer events:

import Link from 'next/link'

export function ProductCard({ slug, name }: { slug: string; name: string }) {
  return (
    <Link href={`/product/${slug}`} prefetch="render">
      {name}
    </Link>
  )
}

This caps the prefetch count at "links currently visible", which is a much smaller number than "links the cursor swept over in the last 30 seconds".

Layer 3: Block the RSC variant at the edge for routes that should never be prefetched. Some routes (checkout pages, account dashboards, anything user-specific) should never end up in the router cache because the payload depends on the request. Add a middleware check:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const NO_PREFETCH_PATHS = ['/checkout', '/account', '/orders']

export function middleware(request: NextRequest) {
  const isRsc = request.headers.get('rsc') === '1'
  const path = request.nextUrl.pathname

  if (isRsc && NO_PREFETCH_PATHS.some((p) => path.startsWith(p))) {
    return new NextResponse(null, { status: 204 })
  }
}

export const config = {
  matcher: ['/checkout/:path*', '/account/:path*', '/orders/:path*'],
}

The rsc: 1 header is set on every prefetch request from Next.js itself. Returning a 204 short-circuits the function invocation, the response is cached at the edge as empty, and the router falls back to a real navigation when the user actually clicks the link. That is the desired behaviour for personalised routes anyway.

Verify the bill is dropping. After deploying the three changes, watch the function invocations chart for the next two hours. The shape should change from a noisy band to a smooth line that tracks actual page views. If you have Vercel Speed Insights enabled, the navigation TTFB metric should hold within 50ms of where it was, because the hover prefetch was buying you almost nothing on routes already covered by viewport prefetch.

The Lesson

Next.js 16 turned on hover prefetch by default and called it a navigation improvement. For megamenus, product grids, and any UI with dense links, it is a self-inflicted RSC stampede. Set prefetch={false} on dense link groups, prefetch="render" on grids you want some prefetch on, and block RSC requests at the edge for routes that should never end up in the router cache. The user-perceived navigation speed barely moves and the function bill drops back to normal.

If your Next.js 16 upgrade has surprised you with a function bill, that is an audit I run for clients on the regular. See my services. For a related caching gotcha after the 16 upgrade, read Next.js 16 unstable_cache migration errors.

Prefetch torching your Vercel bill? Get it fixed.

Back to blogStart a project