8.1 — Prefetching & speculation¶
Module 8 · Lesson 1 · 🔴 Advanced · ~40 min
What you'll learn¶
- Next.js
<Link>prefetching, its defaults, and when they hurt - The Speculation Rules API: prefetch vs prerender, and the eagerness settings
- Predicting the next click without melting your origin
- Measuring whether speculation is paying for itself
The idea and the cost¶
Prefetching makes the next navigation instant by fetching before the click. The cost is bandwidth and origin load for navigations that never happen.
Prefetch hit: navigation feels instant (0–100ms)
Prefetch miss: wasted bytes, wasted origin capacity, competing with the current page
The economics on a commerce PLP: 48 tiles, all prefetched, ~8% click‑through. 92% of that work is waste, and it competes with your product images for bandwidth during load. The technique is right; the default configuration usually isn't.
Next.js <Link> prefetching¶
In production, <Link> prefetches when the link enters the viewport.
prefetch value |
Behavior |
|---|---|
undefined (default) |
Prefetch on viewport entry. For static routes, the full payload; for dynamic routes, the shared layout and loading.tsx |
true |
Prefetch the full route payload |
false |
No automatic prefetch — still prefetches on hover in some versions |
null (where supported) |
Layout‑only prefetch |
// Dense grid: don't prefetch 48 routes on load
{products.map((p, i) => (
<Link
key={p.id}
href={`/p/${p.slug}`}
prefetch={i < 8 ? undefined : false} // first 8 auto, rest on intent
>
<ProductCard product={p} />
</Link>
))}
Diagnose over‑prefetching: load your PLP, open the Network panel, filter by _rsc. If you see
40+ requests on load, they're competing with your images and your LCP is paying for it.
Hover/touch intent prefetching¶
// components/product-tile-link.tsx
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRef } from 'react';
const HOVER_DELAY_MS = 65; // filter out cursor pass-through
export function ProductTileLink({ slug, children }: Props) {
const router = useRouter();
const timer = useRef<ReturnType<typeof setTimeout>>();
const start = () => {
timer.current = setTimeout(() => router.prefetch(`/p/${slug}`), HOVER_DELAY_MS);
};
const cancel = () => clearTimeout(timer.current);
return (
<Link
href={`/p/${slug}`}
prefetch={false}
onMouseEnter={start}
onMouseLeave={cancel}
onFocus={start}
onTouchStart={() => router.prefetch(`/p/${slug}`)} // no delay on touch
>
{children}
</Link>
);
}
The 65 ms hover delay matters: without it, moving the cursor across a grid prefetches every tile it passes over. With it, you prefetch roughly what the user is actually considering.
Timing budget: hover‑to‑click is typically 200–600 ms on desktop; touchstart‑to‑touchend is 80–150 ms on mobile. A prefetch that completes in 150 ms covers most of both.
The Speculation Rules API¶
A browser API (Chromium; check current support and always design the no‑support path as your baseline) that lets you declare what to prefetch or prerender — rendering the entire next page in a hidden tab, so activation is near‑instantaneous.
// app/components/speculation-rules.tsx
export function SpeculationRules() {
const rules = {
prerender: [
{
where: {
and: [
{ href_matches: '/p/*' }, // product pages
{ not: { href_matches: '/checkout*' } }, // never speculate checkout
{ not: { href_matches: '/cart*' } },
{ not: { selector_matches: '[data-no-prerender]' } },
],
},
eagerness: 'moderate', // on hover / pointerdown
},
],
prefetch: [
{
where: { href_matches: '/c/*' },
eagerness: 'moderate',
},
],
};
return (
<script
type="speculationrules"
// Static JSON, no user input — safe to inline
dangerouslySetInnerHTML={{ __html: JSON.stringify(rules) }}
/>
);
}
Eagerness levels¶
| Level | Triggers | Use for |
|---|---|---|
immediate |
As soon as the rule is parsed | A single, near‑certain next page |
eager |
Very early — slight hover intent | High‑confidence links |
moderate |
Hover ~200 ms / pointerdown | The safe default |
conservative |
Pointerdown only | Expensive pages, or high‑volume link grids |
Start at moderate. immediate on a PLP with 48 links would prerender 48 full pages — that's
a self‑inflicted DDoS on your own origin.
Prefetch vs prerender¶
| Prefetch | Prerender | |
|---|---|---|
| Fetches HTML | ✅ | ✅ |
| Runs subresources (JS/CSS/images) | ❌ | ✅ |
| Executes JavaScript | ❌ | ✅ |
| Navigation feel | Fast | Instant |
| Origin cost | 1 HTML request | Full page load, including your APIs |
| Analytics risk | Low | High — see below |
| Memory cost | Low | Significant per prerendered page |
The prerendering hazards¶
Prerendering runs your page for real. Three things break if you're not careful.
1. Analytics double‑counting¶
A prerendered page fires its page‑view event even if the user never navigates to it.
// lib/analytics.ts — defer everything until the page is actually activated
export function trackPageView(data: PageViewData) {
// document.prerendering is true while the page is being speculatively rendered
if ((document as any).prerendering) {
document.addEventListener('prerenderingchange', () => sendPageView(data), { once: true });
return;
}
sendPageView(data);
}
Also check activation state for anything that should only happen for a real visit:
const nav = performance.getEntriesByType('navigation')[0] as any;
const wasPrerendered = nav?.activationStart > 0;
2. Side effects firing early¶
Anything with a consequence — writing to localStorage, incrementing a "recently viewed" list,
starting a countdown, reserving inventory, firing a conversion pixel — must wait for activation.
export function onActivated(fn: () => void) {
if ((document as any).prerendering) {
document.addEventListener('prerenderingchange', fn, { once: true });
} else {
fn();
}
}
// Use it for every effect with an external consequence
useEffect(() => onActivated(() => addToRecentlyViewed(productId)), [productId]);
3. Origin load¶
Every prerender is a real page render. At moderate eagerness on a PLP, expect roughly a 2–5×
increase in requests for the prerendered route type — and it's concentrated on your most popular
pages.
Mitigations:
- Make the prerendered route cacheable (ISR/PPR) so prerenders are edge hits, not origin renders
- conservative eagerness on high‑link‑density pages
- Exclude anything expensive or personalized
- Monitor origin RPS before and after rollout, and be ready to remove the rules
Never prerender cart, checkout, account, logout, or anything that mutates state. Use the
not: { href_matches: ... }exclusions and, belt‑and‑braces, mark those links withdata-no-prerenderand exclude by selector.
Predicting the next click¶
Uniform prefetching is wasteful. Use what you know.
Position‑based (simple, effective)¶
// First-row tiles get real prefetch; the rest wait for intent
{products.map((p, i) => (
<ProductTileLink key={p.id} slug={p.slug} eager={i < 4}>
<ProductCard product={p} priority={i < 4} />
</ProductTileLink>
))}
Data‑driven¶
Your analytics know the actual click distribution from each page type.
-- Where do users go from a PLP? Prefetch the top destinations, not all of them.
SELECT next_page_type, COUNT(*) / SUM(COUNT(*)) OVER () AS share
FROM page_transitions
WHERE from_page_type = 'plp'
GROUP BY 1 ORDER BY share DESC;
-- plp → pdp 0.61
-- plp → plp 0.18 (pagination / another category)
-- plp → search 0.07
-- plp → cart 0.04
61% go to a PDP. That justifies prerendering PDPs from a PLP at moderate eagerness — and tells
you not to bother speculating the other destinations.
Funnel‑stage¶
// From cart, the next step is checkout with ~74% probability.
// This is the one place `immediate` is justified: one link, near-certain.
<script type="speculationrules" dangerouslySetInnerHTML={{ __html: JSON.stringify({
prefetch: [{ urls: ['/checkout/information'], eagerness: 'immediate' }],
}) }} />
Note: prefetch, not prerender, for checkout — you don't want checkout's side effects (payment intent creation, inventory reservation) running speculatively.
Prefetching data, not just pages¶
// Prefetch the PDP's dynamic data on hover, so the dynamic hole is warm
'use client';
import { mutate } from 'swr';
const prefetchProductData = (slug: string) => {
void mutate(`/api/product/${slug}/price`, fetch(`/api/product/${slug}/price`).then(r => r.json()));
};
For images, warm the LCP image of the likely next page:
onMouseEnter={() => {
const img = new Image();
img.fetchPriority = 'low'; // don't compete with the current page
img.src = product.heroImageUrl;
}}
fetchPriority = 'low' is essential here — a speculative image at normal priority competes with
the current page's LCP.
Measuring whether it's worth it¶
Track three numbers:
// lib/prefetch-metrics.ts
let speculated = 0;
let used = 0;
export function recordSpeculation(url: string) {
speculated++;
sessionStorage.setItem(`spec:${url}`, String(Date.now()));
}
export function recordNavigation(url: string) {
const t = sessionStorage.getItem(`spec:${url}`);
if (t) {
used++;
reportMetric({ name: 'speculation_hit', value: Date.now() - Number(t) });
}
reportMetric({ name: 'speculation_hit_rate', value: speculated ? used / speculated : 0 });
}
| Metric | Meaning | Target |
|---|---|---|
| Hit rate | speculated pages that were actually visited | > 25% for prerender, > 15% for prefetch |
| Wasted bytes | bytes fetched for unvisited pages | < 15% of session bytes |
| Origin RPS delta | extra load from speculation | < 2× on the speculated route |
If hit rate is under 15% with prerendering, tighten eagerness or narrow the rules. If origin RPS more than doubles, make the route cacheable first.
bfcache: the free prefetch¶
Back/forward cache stores the entire page — DOM, JS heap, scroll position — so back navigation is instantaneous, no network, no re‑render. It's better than any prefetch and costs nothing.
Most sites break it accidentally. → 8.3
// Check whether your page is bfcache-eligible (Chrome)
// DevTools → Application → Back/forward cache → "Test back/forward cache"
Fix bfcache before you invest in speculation. Back navigation is a huge share of commerce traffic (PDP → back → PLP → PDP is the browsing pattern), and it's free.
Aurora's speculation setup¶
// app/layout.tsx
const RULES = {
prerender: [{
where: {
and: [
{ href_matches: '/p/*' },
{ not: { href_matches: '/(cart|checkout|account|logout)*' } },
{ not: { selector_matches: '[data-no-prerender]' } },
],
},
eagerness: 'moderate',
}],
prefetch: [{
where: { href_matches: '/c/*' },
eagerness: 'moderate',
}],
};
Plus: prefetch={false} on PLP tiles beyond the first 8, hover‑intent prefetch with a 65 ms delay,
and low‑priority hero image warming.
| Metric | Before | After |
|---|---|---|
| PLP → PDP navigation p75 | 1,840 ms | 210 ms |
| Prerender hit rate | — | 31% |
Origin RPS on /p/* |
baseline | +48% |
| PLP LCP (fewer competing prefetches) | 2,510 ms | 2,290 ms |
| Wasted bytes per session | — | 9% |
Note the PLP LCP improved — because they stopped prefetching 48 routes on load. Turning prefetching down was worth as much as turning speculation on.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Prefetching every link on a dense grid | Competes with images; hurts LCP |
eagerness: 'immediate' on many links |
Self‑inflicted origin load spike |
| Prerendering cart/checkout | Side effects fire without a real visit |
| Analytics not prerender‑aware | Inflated page views, corrupted funnel data |
| Speculative images at normal priority | Competes with the current page's LCP |
| Speculating uncacheable routes | Every speculation is a full origin render |
| Not measuring hit rate | Paying for waste indefinitely |
| Investing in speculation while bfcache is broken | Ignoring the free win |
Lab 8.1 — Speculation audit¶
- Count prefetches on load: Network panel, filter
_rsc, load your PLP. If it's over ~10, reduce it and re‑measure LCP. - Fix bfcache first (8.3). Measure back‑navigation timing before and after.
- Pull your transition data. Where do users actually go from each page type? Speculate only the top destination.
- Add Speculation Rules at
moderatefor your highest‑confidence transition, with explicit exclusions for cart/checkout/account. - Make analytics prerender‑aware before enabling prerendering, or you'll corrupt a week of funnel data.
- Monitor origin RPS for the speculated route for 48 hours.
- Measure hit rate. Under 15%? Tighten eagerness or narrow the rules.
Checklist¶
- bfcache working before any speculation work
-
<Link prefetch>audited on dense grids - Hover‑intent prefetching with a delay, not on every pointer pass
- Speculation Rules at
moderateorconservative, neverimmediatefor many links - Cart, checkout, account, and logout explicitly excluded
- Analytics and side effects gated on
prerenderingchange - Speculative images at
fetchPriority: 'low' - Speculated routes are cacheable
- Hit rate, wasted bytes, and origin RPS monitored