Prefetching & Preloading Routes
A single-page application spends its idle moments doing nothing while the network sits quiet and the CPU coasts. Speculative loading turns that dead time into a head start: the browser fetches the JavaScript chunk and the data behind the route a user is about to visit, so that when the click finally lands the transition feels instantaneous. This page explains the full spectrum of route prefetching — from the humble <link rel="prefetch"> through modulepreload, priority hints, and the Speculation Rules API — and shows how to wire a hover-triggered controller that warms the next route without wasting a byte on connections that never happen.
← Back to Routing Architecture & Fundamentals
The Problem
When a user clicks an internal link in a code-split application, several things must happen before the next view paints: the route’s JavaScript chunk has to be requested, downloaded, parsed, and executed, and then that code usually issues its own data fetch before it can render anything meaningful. On a warm desktop connection this chain costs a couple of hundred milliseconds; on a mid-tier phone over a congested cellular link it can stretch past a second. That delay lands squarely on the interaction, which is exactly where users perceive sluggishness most acutely.
The obvious fix — bundle everything upfront so no chunk is ever missing — trades one problem for a worse one. It inflates the initial download, delays the first meaningful paint, and forces the browser to parse code for routes most visitors will never open. Route-based code splitting exists precisely to avoid that, breaking the application into per-route chunks loaded on demand. Prefetching is the missing half of that strategy: it keeps the small initial bundle but hides the on-demand cost by fetching the next chunk during the idle window before the user commits.
The tension, then, is between latency and waste. Fetch too eagerly and you burn the user’s data allowance and clog the connection with chunks for routes they never open — a genuine cost on metered mobile plans and a measurable regression on low-end devices where every parsed kilobyte competes for the main thread. Fetch too late, or not at all, and every navigation pays full freight. The craft of prefetching is spending bandwidth speculatively but cheaply: choosing a signal that predicts navigation well (hover, viewport visibility, an explicit intent gesture), fetching at a priority low enough not to contend with what the current page still needs, and backing off entirely when the user has signalled they are on a constrained connection.
Core API & Primitives
The browser exposes several declarative and imperative mechanisms for speculative loading, each with a different priority, cache destination, and intended payload. Knowing which one to reach for is most of the battle.
<link rel="prefetch"> requests a resource at the lowest priority and parks it in the HTTP cache for a future navigation. It is the right tool for a route chunk you expect to need soon but not this instant — the browser fetches it only when the network is otherwise idle, and it never blocks the current page. <link rel="preload"> is the opposite: a high-priority, mandatory fetch for something the current page needs imminently, with an as attribute declaring the resource type so the browser applies the correct priority and Accept headers. <link rel="modulepreload"> is the ES-module-aware variant — it fetches, parses, and populates the module map, so a subsequent import() resolves from memory instead of re-fetching.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
type SpeculativeRel = "prefetch" | "preload" | "modulepreload";
interface HintOptions {
as?: "script" | "fetch" | "style" | "document";
crossOrigin?: "anonymous" | "use-credentials";
fetchPriority?: "high" | "low" | "auto";
}
function injectHint(href: string, rel: SpeculativeRel, opts: HintOptions = {}): HTMLLinkElement {
// Guard against duplicate hints for the same target.
const existing = document.head.querySelector<HTMLLinkElement>(
`link[rel="${rel}"][href="${CSS.escape(href)}"]`,
);
if (existing) return existing;
const link = document.createElement("link");
link.rel = rel;
link.href = href;
if (opts.as) link.as = opts.as;
if (opts.crossOrigin) link.crossOrigin = opts.crossOrigin;
if (opts.fetchPriority) link.fetchPriority = opts.fetchPriority;
document.head.appendChild(link);
return link;
}
Two further primitives matter. Priority Hints — the fetchPriority attribute on <link>, <img>, <script>, and the fetch() options bag — let you nudge a resource up or down the browser’s internal priority queue, so a prefetch never starves the current view’s critical requests. The Speculation Rules API goes further still: a JSON <script type="speculationrules"> block declares URLs the browser may prefetch or prerender entire documents for, using its own heuristics and an eagerness knob. For classic multi-page applications this can prerender the next document so the navigation is a genuine instant swap; for a client-side-routed SPA the prefetch action still usefully warms the HTML and its subresources.
// TypeScript 5.x — Speculation Rules injected at runtime (Chromium 121+)
interface SpeculationRule {
source: "list" | "document";
urls?: string[];
eagerness?: "immediate" | "eager" | "moderate" | "conservative";
}
function addSpeculationRules(prefetch: SpeculationRule[]): void {
if (!HTMLScriptElement.supports?.("speculationrules")) return; // graceful no-op
const script = document.createElement("script");
script.type = "speculationrules";
script.textContent = JSON.stringify({ prefetch });
document.head.appendChild(script);
}
Finally, the Network Information API exposes navigator.connection, whose saveData flag and effectiveType estimate let you switch prefetching off entirely for users who have opted into data saving or are on a slow link. Every strategy below consults it before spending a byte.
Step-by-Step Implementation
The goal is a hover-prefetch controller: when a pointer settles on an internal link, it fetches that route’s chunk and warms its data, debounced so a cursor sweeping across a nav bar does not fire a dozen requests, and silent on constrained connections. The prerequisite is a route table that maps a pathname to a lazy chunk loader and an optional data warmer — exactly the shape route-based code splitting already produces.
Step 1: Gate on connection quality
Before wiring any listeners, decide whether prefetching should run at all. Reading saveData and effectiveType once and caching the verdict keeps the hot path cheap, while listening for change lets the decision follow a user who moves from wifi to cellular.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
interface NetworkInformation extends EventTarget {
readonly saveData: boolean;
readonly effectiveType: "slow-2g" | "2g" | "3g" | "4g";
}
function prefetchAllowed(): boolean {
const conn = (navigator as Navigator & { connection?: NetworkInformation }).connection;
if (!conn) return true; // API absent (Safari/Firefox) — assume a capable link
if (conn.saveData) return false; // user explicitly opted into data saving
return conn.effectiveType === "4g"; // skip on 3g and below
}
Step 2: Build a memoised warmer per route
Each route needs a single idempotent function that both imports its chunk and kicks off its data fetch, memoised so repeated hovers never re-request. Returning the in-flight promise means a click that arrives mid-prefetch simply awaits the same work rather than starting over.
// TypeScript 5.x — framework-agnostic
interface RouteEntry {
load: () => Promise<unknown>; // dynamic import of the route chunk
warmData?: (path: string) => Promise<unknown>; // optional data prime
}
function createWarmer(routes: Map<string, RouteEntry>) {
const inFlight = new Map<string, Promise<unknown>>();
return function warm(path: string): Promise<unknown> {
const cached = inFlight.get(path);
if (cached) return cached; // memoise: one fetch per path, ever
const entry = routes.get(path);
if (!entry) return Promise.resolve();
const work = Promise.all([
entry.load(),
entry.warmData?.(path) ?? Promise.resolve(),
]).catch(() => {
// A failed prefetch must never surface: the real navigation retries.
inFlight.delete(path);
});
inFlight.set(path, work);
return work;
};
}
Step 3: Debounce the hover signal
A pointer crossing a dense navigation region triggers mouseover on many links in quick succession. A short intent delay — around 60–120 ms of dwell — filters out incidental passes and only warms links the user actually rests on. pointerdown is treated as a stronger, immediate signal because it almost always precedes a click.
// TypeScript 5.x — framework-agnostic
function createHoverPrefetcher(warm: (path: string) => Promise<unknown>, dwellMs = 80) {
let timer: number | undefined;
const toPath = (el: EventTarget | null): string | null => {
const anchor = (el as Element | null)?.closest?.("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return null;
const url = new URL(anchor.href, location.href);
// Same-origin internal links only; never speculate cross-origin.
return url.origin === location.origin ? url.pathname : null;
};
const onOver = (e: Event) => {
const path = toPath(e.target);
if (!path) return;
clearTimeout(timer);
timer = window.setTimeout(() => warm(path), dwellMs);
};
const onOut = () => clearTimeout(timer);
const onDown = (e: Event) => {
const path = toPath(e.target);
if (path) warm(path); // commit immediately on press
};
return { onOver, onOut, onDown };
}
Step 4: Wire delegated listeners and respect the gate
Attach the listeners once at the document root — event delegation means links added later by the router are covered automatically, with no per-link bookkeeping. The connection gate short-circuits the whole thing when the user is on a constrained link.
// TypeScript 5.x — framework-agnostic
function installPrefetching(routes: Map<string, RouteEntry>): () => void {
if (!prefetchAllowed()) return () => {}; // silent no-op on slow/save-data links
const warm = createWarmer(routes);
const { onOver, onOut, onDown } = createHoverPrefetcher(warm);
document.addEventListener("mouseover", onOver, { passive: true });
document.addEventListener("mouseout", onOut, { passive: true });
document.addEventListener("pointerdown", onDown, { passive: true });
return () => {
document.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseout", onOut);
document.removeEventListener("pointerdown", onDown);
};
}
Step 5: Prefetch on visibility for off-pointer devices
Hover is meaningless on touch screens, so pair the pointer path with an IntersectionObserver that warms links as they scroll into view — throttled to the most likely candidates by only observing links inside the primary content region. This catches the majority of mobile navigations without a pointer ever moving.
// TypeScript 5.x — framework-agnostic
function observeVisibleLinks(warm: (path: string) => Promise<unknown>, root: Element): () => void {
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const href = (entry.target as HTMLAnchorElement).href;
const url = new URL(href, location.href);
if (url.origin === location.origin) warm(url.pathname);
io.unobserve(entry.target); // warm once, then stop watching
}
}, { rootMargin: "200px" }); // start a little before the link is on screen
root.querySelectorAll("a[href]").forEach((a) => io.observe(a));
return () => io.disconnect();
}
Verification & Testing
Speculative fetches are invisible to the user by design, so verification means watching the network directly. In DevTools, open the Network panel, filter to your chunk names, and hover a link: a correctly wired controller issues a request within the dwell window, and the request row shows a Lowest priority for a prefetch hint versus High for a preload. Hovering the same link twice must produce exactly one request — if you see a second, memoisation in Step 2 is broken. Then click the link and confirm the navigation resolves the chunk from (prefetch cache) or (disk cache) rather than issuing a fresh network fetch; that cache hit is the entire payoff.
// @playwright/test v1.44 — assert a hover warms the route chunk
import { test, expect } from "@playwright/test";
test("hovering a link prefetches its chunk exactly once", async ({ page }) => {
const requests: string[] = [];
page.on("request", (r) => {
if (/\/chunks\/settings\.[a-f0-9]+\.js$/.test(r.url())) requests.push(r.url());
});
await page.goto("/");
await page.hover("a[href='/settings']");
await page.waitForTimeout(150); // allow the dwell debounce to fire
expect(requests).toHaveLength(1); // warmed once, not per mousemove
await page.click("a[href='/settings']");
await expect(page).toHaveURL(/\/settings$/);
expect(requests).toHaveLength(1); // navigation reused the prefetch, no refetch
});
To confirm the connection gate, emulate a slow link: in DevTools throttle to Slow 3G (or set the saveData override) and repeat the hover — no chunk request should appear at all.
Performance Tuning
- Keep prefetch priority genuinely low. A
prefetchhint or afetch(..., { priority: "low" })yields to the current view’s critical requests, so it never delays LCP on the page the user is still reading. ReservepreloadandfetchPriority: "high"for the resource the present route needs, never for a speculative one. - Warm data, not just code. Importing the chunk removes the parse cost but leaves the data round-trip on the critical path of the transition. Priming the route’s fetch — into the same cache your data layer reads on mount — is what makes the post-click render synchronous, which is where the INP win comes from.
- Cap concurrent speculation. On a long list, an
IntersectionObservercan warm dozens of routes at once and saturate the connection. Bound the number of in-flight prefetches (a small semaphore) so speculation never competes with a real navigation the user has already started. - Let the browser own document-level prefetch. Where the Speculation Rules API is available, a
moderateorconservativeeagerness delegates the hover/dwell heuristics to the browser, which has better signals than your JavaScript and can prerender rather than merely prefetch — often the single largest transition-latency win available. - Measure the cache-hit rate, not the hover count. The metric that matters is the fraction of navigations served from the prefetch cache. A high hover-prefetch count with a low hit rate means you are spending bandwidth on links nobody clicks; tighten the dwell threshold or switch to
pointerdown-only.
Gotchas & Failure Modes
- Over-prefetching drains the data allowance. Warming every visible link on a content-dense page can download megabytes the user never asked for. Gate on
saveData, cap concurrency, and prefer a dwell or pointer-intent signal over blanket viewport warming on mobile. - Memory pressure from held modules. Every imported chunk stays resident in the module map for the page’s lifetime. Prefetching dozens of routes on a low-end device inflates the heap and can trigger reclaim pauses; warm reachable routes, not the entire table.
- Ignoring
saveDatais a real regression. Users on metered plans who enable data saving expect the site to honour it. Fetching speculatively anyway is both a courtesy failure and a measurable cost — always short-circuit whennavigator.connection.saveDatais true. - Stale prefetched data. A warmed data payload can go out of date between the hover and a click that arrives seconds later. Give warmed entries the same freshness policy as normally-fetched data so the transition never renders a stale view.
- Prefetching cross-origin or authenticated routes. Speculatively fetching a URL with side effects, or one that requires credentials the prefetch omits, wastes the request or triggers server work. Restrict warming to same-origin, side-effect-free GET routes.
- Double-firing on touch devices. A
pointerdownhandler plus a hover handler can both fire on hybrid touch-and-pointer devices. Memoisation makes the duplicate harmless, but verify the request count in the Network panel rather than assuming.
Go Deeper
- Prefetching Routes on Link Hover — a focused build of the hover/pointerdown prefetcher, debounced and
saveData-aware, with the data-warming step spelt out end to end.
Related
- Routing Architecture & Fundamentals — the parent overview tightening the link between matching, code splitting, and navigation performance.
- Route-Based Code Splitting — the per-route chunking strategy that prefetching complements by hiding the on-demand load cost.
- SPA vs MPA Tradeoffs — whether client-side navigation, and therefore speculative chunk loading, is the right model for your application at all.
- Prefetching Routes on Link Hover — the dedicated hover-intent implementation with prerequisites, verification, and a FAQ.