The Problem
A client's Vercel bill jumped 4x the week after upgrading from Next.js 15.4 to 16.2. Page traffic was flat. Function invocations were flat. The line that exploded was Edge Requests — specifically, RSC payload requests with the ?_rsc= query param, sitting at roughly twelve times the page view count on their highest-traffic listing page.
The Vercel dashboard made it obvious. Every product card on a category page was generating two prefetch requests as soon as it scrolled into the viewport: one for the static shell and one for the dynamic segment. The page had thirty cards above the fold. A single page view was firing sixty prefetches before the user moved the mouse.
I confirmed it in the Network tab:
GET /products/sku-001?_rsc=abc123 → 204 No Content
GET /products/sku-001?_rsc=abc123 → 200 OK (5.2 KB)
GET /products/sku-002?_rsc=def456 → 204 No Content
GET /products/sku-002?_rsc=def456 → 200 OK (4.8 KB)
...
Every link was making both an intent-prefetch and a full-prefetch within the same scroll event. The intent-prefetch returned 204 because the link was not yet hovered, but Vercel counts it as a billed Edge Request anyway.
Why It Happens
Next.js 16 changed the prefetch default on <Link> from true (auto, only static) to 'auto' (a new tri-state). The new value behaves differently than the old true:
- On viewport entry: fires a "loose" prefetch for the static shell.
- On hover or focus: fires a "tight" prefetch for the full RSC payload.
- On click: hydrates whatever the latest prefetch returned.
The change shipped with the new client cache model and the PPR work. The intent was clever: most users never hover the cards they see, so the heavy RSC payload only fetches for links the user actually shows interest in. The problem is on a long product grid the viewport-entry prefetch fires for every card, and the hover-prefetch fires on top of it the moment the cursor crosses any card. Net result on Vercel Edge: two billed requests per Link instead of one.
It is worse with loading="lazy" images. The Intersection Observer that triggers the prefetch shares its callback with the image lazy-load observer, and on certain Safari/iOS versions it fires the prefetch twice as the card enters and exits the rootMargin band during fast scrolls. I saw this most on iPhones doing inertia scrolls through long category pages.
The deeper trap: an RSC prefetch that hits a dynamic segment without PPR enabled gets cached as a MISS, meaning every subsequent prefetch for the same URL is also billed. The static segment, by contrast, returns from the Edge cache and costs nothing after the first fetch. So a non-PPR app gets the worst of both: heavy prefetches, no Edge cache hits.
The Fix
Three changes, applied in order.
Step 1: Set explicit prefetch behaviour on grid links. The default 'auto' is fine for navigation menus where there are five or ten links. It is wrong for product grids or article lists where you have dozens.
// app/category/[slug]/ProductCard.tsx
import Link from 'next/link'
export function ProductCard({ product }: { product: Product }) {
return (
<Link
href={`/products/${product.slug}`}
prefetch={false}
onMouseEnter={(e) => {
// Manually trigger prefetch on actual hover.
e.currentTarget.dispatchEvent(
new MouseEvent('mouseover', { bubbles: true })
)
}}
>
<article>{product.name}</article>
</Link>
)
}
prefetch={false} disables the viewport-entry prefetch entirely. The router still prefetches on hover because that is hardcoded into the Link click path, so navigation feels instant for users who actually intend to click. The difference shows up in the Edge Request count immediately.
Step 2: Enable PPR so dynamic prefetches hit the static shell first. If you must keep prefetch enabled on grid links — say, for a small set of feature cards — turn on partial prerendering so the static shell caches at the Edge and the prefetch returns 304 on repeat visits.
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default config
Then opt the route in:
// app/products/[slug]/page.tsx
export const experimental_ppr = true
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
return (
<>
<ProductShell slug={slug} />
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice slug={slug} />
</Suspense>
</>
)
}
With PPR on, the prefetch returns the static shell from the Edge cache. The dynamic portion only fetches if the user actually clicks through. Edge Request count for repeat prefetches drops to near zero.
Step 3: Throttle aggressive Intersection Observer prefetches on long grids. If a page renders more than ~20 Link elements in the viewport, the per-link prefetch overhead dominates regardless of PPR. Render a placeholder for off-screen links and only mount the real Link when the user scrolls within a tighter rootMargin:
'use client'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
export function LazyLink({ href, children }: { href: string; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(
([entry]) => entry.isIntersecting && setInView(true),
{ rootMargin: '100px' },
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return (
<div ref={ref}>
{inView ? <Link href={href}>{children}</Link> : <a href={href}>{children}</a>}
</div>
)
}
The plain <a> does no prefetching and no client-side navigation — it falls back to a full page load if a user clicks before the Link mounts. That is fine for off-screen links a user is unlikely to reach anyway.
The Lesson
Next.js 16's new prefetch="auto" default is the right call for nav menus and wrong for long grids. Set prefetch={false} on list items, enable PPR so the static shell caches at the Edge, and lazy-mount Link on dense pages. The Vercel Edge Request count drops back to one-per-page on the first day after deploying.
If your Vercel bill jumped after a Next.js 16 upgrade and the dashboard is not making the cause obvious, that is the work I do. See my services. For another caching-after-upgrade trap, read Next.js use cache returning stale data.
Vercel bill jumped after a Next.js upgrade? Get it shipped.
