Skip to content

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 with data-no-prerender and 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

  1. Count prefetches on load: Network panel, filter _rsc, load your PLP. If it's over ~10, reduce it and re‑measure LCP.
  2. Fix bfcache first (8.3). Measure back‑navigation timing before and after.
  3. Pull your transition data. Where do users actually go from each page type? Speculate only the top destination.
  4. Add Speculation Rules at moderate for your highest‑confidence transition, with explicit exclusions for cart/checkout/account.
  5. Make analytics prerender‑aware before enabling prerendering, or you'll corrupt a week of funnel data.
  6. Monitor origin RPS for the speculated route for 48 hours.
  7. 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 moderate or conservative, never immediate for 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

Next: 8.2 Offloading to workers