Incremental Prefetching in Next.js 16: Smarter Route Loading
Our e-commerce category page had 48 product links. In Next.js 15, hovering triggered prefetching that downloaded 2.4MB of JavaScript and data—the full page payload times 48. Users on mobile data complained about unexpected data usage. Next.js 16's incremental prefetching changed this entirely. The same page now prefetches 180KB total. Here's how the new system works.
The Previous Prefetching Model
Next.js has always prefetched linked pages to make navigation feel instant. When a <Link> component enters the viewport or receives hover, Next.js downloads the destination page's assets.
The problem: it downloaded everything. Every product link triggered a full prefetch—JavaScript bundle, data payload, the works. Shared layouts downloaded repeatedly. A category page with 50 products meant potentially downloading the same layout 50 times.
// Each of these prefetched independently
<Link href="/products/shoe-1">Shoe 1</Link>
<Link href="/products/shoe-2">Shoe 2</Link>
<Link href="/products/shoe-3">Shoe 3</Link>
// ... 47 more links, each downloading the full /products/[slug] layoutIncremental Prefetching
Next.js 16 prefetches only what isn't already cached. The first product link prefetches:
- The shared
/products/[slug]layout (once) - The specific product's data
- Any unique JavaScript for that page
Subsequent links prefetch only their unique data. The layout is already cached.
First link prefetch:
├── layout.js (shared) ✓ Download
├── page.js (shared) ✓ Download
└── product-data (unique) ✓ Download
Second link prefetch:
├── layout.js (shared) ✗ Cached
├── page.js (shared) ✗ Cached
└── product-data (unique) ✓ DownloadNo configuration needed. This happens automatically in Next.js 16.
Layout Deduplication
The layout deduplication system tracks which layouts have been prefetched. When multiple links share a layout, it downloads once and reuses:
Category page with 50 product links:
Next.js 15:
- Layout downloaded: 50 times
- Total transfer: 50 × 48KB = 2.4MB
Next.js 16:
- Layout downloaded: 1 time
- Product data: 50 × 3KB = 150KB
- Total transfer: 48KB + 150KB = 198KB
Reduction: 92%The math improves with more links. A page with 100 product links sees even greater savings because the shared layout cost amortizes further.
Viewport-Aware Behavior
Next.js 16 adds intelligence about which links matter:
Viewport exit cancellation: When a link scrolls out of view, its pending prefetch request cancels. Users scrolling quickly through a long list don't trigger dozens of useless requests.
Hover prioritization: Links under the cursor get priority. If the user hovers a link that hasn't prefetched yet, it jumps the queue.
Re-entry handling: If a link re-enters the viewport after scrolling away, it re-queues for prefetching. But if the data was already cached, no new request fires.
Invalidation awareness: If cached data becomes stale (revalidation timer expires), prefetching fetches fresh data on the next trigger.
Measuring the Impact
I instrumented a category page to measure prefetch behavior:
// Monitoring prefetch requests
useEffect(() => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes('/_next/')) {
console.log('Prefetch:', entry.name, entry.transferSize);
}
}
});
observer.observe({ entryTypes: ['resource'] });
return () => observer.disconnect();
}, []);Results on our category page:
| Metric | Next.js 15 | Next.js 16 | |--------|------------|------------| | Prefetch requests | 48 | 52 | | Total transfer | 2.4MB | 198KB | | Time to prefetch all | 4.2s | 0.8s | | Navigation delay | 120ms | 40ms |
More requests, but far less data. The increased request count comes from more granular fetching. Each request is tiny compared to the full-page downloads before.
How It Works with Cache Components
Next.js 16's Cache Components feature works seamlessly with incremental prefetching. Pages using use cache get their cached portions served from the edge, with only dynamic parts fetched:
// app/products/[slug]/page.tsx
import { unstable_cache } from 'next/cache';
async function getProductDetails(slug: string) {
'use cache';
// This portion caches at the edge
return db.products.findBySlug(slug);
}
async function getInventory(slug: string) {
// This fetches fresh each time
return inventory.check(slug);
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const [product, inventory] = await Promise.all([
getProductDetails(params.slug),
getInventory(params.slug),
]);
return (
<div>
<ProductInfo product={product} />
<InventoryStatus inventory={inventory} />
</div>
);
}When prefetching this page, Next.js fetches:
- The cached product details (from edge cache if available)
- The fresh inventory data
- Only the dynamic portions of the layout
The static shell renders immediately on navigation, with dynamic parts streaming in.
Prefetch Priority Hints
You can hint prefetch priority using the prefetch prop:
// High priority - prefetch immediately when in viewport
<Link href="/products/featured" prefetch={true}>
Featured Product
</Link>
// Default - prefetch on viewport entry with standard priority
<Link href="/products/regular">
Regular Product
</Link>
// Disabled - only prefetch on hover
<Link href="/products/rarely-visited" prefetch={false}>
Rarely Visited
</Link>The default behavior in Next.js 16 is smarter than before. Even with prefetch={true}, the incremental system applies—only uncached portions download.
Network Considerations
Incremental prefetching respects connection quality:
// Next.js automatically adjusts based on connection
// On slow connections or Save-Data header:
// - Prefetching becomes more conservative
// - Only high-priority links prefetch
// - Viewport triggers may be delayedYou can also detect this in your components:
function ProductGrid({ products }) {
const [saveData, setSaveData] = useState(false);
useEffect(() => {
const connection = navigator.connection;
if (connection?.saveData) {
setSaveData(true);
}
}, []);
return (
<div className="grid grid-cols-4 gap-4">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
prefetch={saveData ? false : undefined}
>
<ProductCard product={product} />
</Link>
))}
</div>
);
}Debugging Prefetch Behavior
The Next.js DevTools (covered in my previous post) show prefetch activity:
User: Show prefetch activity for /category/shoes
AI: Analyzing prefetch logs for this route:
Prefetched links: 48
- Layout fetched: 1 time (48KB)
- Unique data fetched: 48 times (avg 3.2KB each)
- Cancelled requests: 12 (viewport exit)
- Cache hits: 36 (returning visitors)
Total transfer this session: 198KB
Estimated previous version: 2.3MB
No issues detected. Prefetching working as expected.Migration Notes
Incremental prefetching activates automatically in Next.js 16. No code changes required. However, be aware:
Request patterns change: Monitoring tools may show more requests. This is expected—you're trading large monolithic requests for many small ones.
CDN considerations: If your CDN charges per-request, costs might increase slightly. However, bandwidth charges typically decrease significantly, resulting in net savings.
Caching headers matter more: Since prefetching is granular, ensure your API routes return appropriate cache headers. Poorly cached dynamic data triggers repeated fetches.
// Ensure proper caching headers on API routes
export async function GET(request: Request) {
const data = await fetchData();
return Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
},
});
}Real-World Results
After upgrading our e-commerce site:
Mobile data usage dropped 85% on category pages. Users browse more products without data anxiety.
Navigation feels instant. The perceived latency dropped from 120ms to under 40ms because most data is already cached when users click.
Server load decreased paradoxically. Despite more requests, the total bandwidth served dropped. Edge caching handles most requests, reducing origin load.
The incremental prefetching model in Next.js 16 represents a fundamental improvement in how client-side navigation works. Rather than optimistically downloading everything, it downloads precisely what's needed. The result is faster navigation with less waste—exactly what users on any connection need.