Prefetching Routes on Link Hover
After reading this you will be able to attach a single delegated handler that detects when a user rests their pointer on an internal link, then imports that route’s code chunk and warms its data a beat before the click arrives — debounced against incidental cursor movement and silent on data-saver connections.
← Back to Prefetching & Preloading Routes
Prerequisites
Core Concept
Hover is the earliest reliable signal that a navigation is coming. The interval between a pointer settling on a link and the click that follows is typically 150–400 ms on desktop — a free window in which the browser can import the route’s chunk and issue its data request, so the eventual click resolves from memory instead of the network. The mechanism is deliberately modest: a mouseover listener at the document root reads the anchor’s pathname, waits a short dwell to reject cursors merely passing through, and calls a memoised warmer. pointerdown acts as a stronger, immediate signal since it almost always precedes the click by a few tens of milliseconds.
The whole thing must be cheap and polite. It warms each route at most once, it never fetches for a user who has enabled data saving, and a failed speculative fetch stays invisible because the real navigation will retry it. This is speculation, not commitment: if the prediction is wrong the only cost is one low-priority request, and if it is right the transition becomes instant.
Implementation
The controller below delegates from the document root, so links the router injects later need no wiring. It reads navigator.connection.saveData before doing anything, debounces the hover with a dwell timer, treats pointerdown as an immediate commit, and memoises so a link warmed on hover is not re-fetched on the subsequent press.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
interface RouteEntry {
load: () => Promise<unknown>; // dynamic import of the chunk
warmData?: (path: string) => Promise<unknown>; // optional data prime
}
interface NetworkInformation {
readonly saveData: boolean;
readonly effectiveType: "slow-2g" | "2g" | "3g" | "4g";
}
export function installHoverPrefetch(
routes: Map<string, RouteEntry>,
dwellMs = 80,
): () => void {
const conn = (navigator as Navigator & { connection?: NetworkInformation }).connection;
// Honour data-saver and skip slow links; absent API implies a capable connection.
if (conn && (conn.saveData || conn.effectiveType !== "4g")) return () => {};
const inFlight = new Map<string, Promise<unknown>>();
let timer: number | undefined;
const warm = (path: string): Promise<unknown> => {
const cached = inFlight.get(path);
if (cached) return cached; // memoise: one fetch per path
const entry = routes.get(path);
if (!entry) return Promise.resolve();
const work = Promise.all([
entry.load(), // parse cost paid ahead of the click
entry.warmData?.(path) ?? Promise.resolve(), // data primed into the shared cache
]).catch(() => {
inFlight.delete(path); // let the real navigation retry
});
inFlight.set(path, work);
return work;
};
const pathFor = (target: EventTarget | null): string | null => {
const anchor = (target as Element | null)?.closest?.("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return null;
const url = new URL(anchor.href, location.href);
return url.origin === location.origin ? url.pathname : null; // same-origin only
};
const onOver = (e: Event) => {
const path = pathFor(e.target);
if (!path) return;
clearTimeout(timer);
timer = window.setTimeout(() => warm(path), dwellMs); // debounce incidental passes
};
const onOut = () => clearTimeout(timer);
const onDown = (e: Event) => {
const path = pathFor(e.target);
if (path) warm(path); // press is a firm commit — warm now
};
document.addEventListener("mouseover", onOver, { passive: true });
document.addEventListener("mouseout", onOut, { passive: true });
document.addEventListener("pointerdown", onDown, { passive: true });
return () => {
clearTimeout(timer);
document.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseout", onOut);
document.removeEventListener("pointerdown", onDown);
};
}
The warmData hook is what turns a fast chunk load into an instant transition: prime it into the very cache your data layer reads on mount — a query client, an in-memory store, or the browser’s HTTP cache — so the post-click render finds its data already resolved and never issues a second request.
Verification
Watch the Network panel while hovering. A correctly wired controller issues one request for the route chunk after the dwell interval, at Lowest priority, and hovering the same link twice must not produce a second request. Click through and confirm the navigation resolves the chunk from cache rather than re-fetching. Automate the same check with Playwright.
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("hover warms the chunk once and the click reuses it", async ({ page }) => {
const hits: string[] = [];
page.on("request", (r) => {
if (/\/chunks\/reports\.[a-f0-9]+\.js$/.test(r.url())) hits.push(r.url());
});
await page.goto("/");
await page.hover("a[href='/reports']");
await page.waitForTimeout(150); // let the dwell timer fire
expect(hits).toHaveLength(1); // warmed exactly once
await page.click("a[href='/reports']");
await expect(page).toHaveURL(/\/reports$/);
expect(hits).toHaveLength(1); // navigation reused the prefetch
});
Gotchas
- A cursor sweeping across a dense navigation bar fires
mouseoveron every link; without the dwell debounce that becomes a burst of requests for routes the user never opens. Keep the dwell around 60–120 ms. - On hybrid touch-and-pointer devices both
pointerdownand a hover event can fire for one interaction. Memoisation makes the duplicate harmless, but confirm the request count in the Network panel rather than assuming. - Warmed data can go stale between a hover and a click that arrives seconds later; give warmed entries the same freshness policy as normally-fetched data so the transition never paints an outdated view.
- Never warm cross-origin or side-effecting routes — the same-origin pathname guard above keeps speculation to safe GET navigations and avoids triggering server work for a click that may never come.
FAQ
Should I prefetch on mouseover or mouseenter? Use mouseover with event delegation at the document root so a single listener covers every link, including those the router injects later; mouseenter does not bubble, forcing a per-link listener that must be re-attached whenever the DOM changes.
How long should the dwell debounce be? Between 60 and 120 milliseconds works well: long enough to reject a cursor merely crossing a link, short enough that a deliberate hover still leaves time to fetch before the click. Tune it against your measured cache-hit rate rather than guessing.
Does hovering waste bandwidth on people who never click? A little, which is why the controller gates on saveData, restricts warming to same-origin routes, and fetches at the lowest priority. The bet is favourable: one small low-priority request against a visibly instant transition when the prediction is right.
Why import the chunk and warm data separately? Importing the chunk removes only the download and parse cost; the route still issues its own data request on mount. Priming that request in parallel means both are resolved before the click, so the post-navigation render is synchronous instead of showing a loading state.
What happens if the prefetch request fails? Nothing visible — the warmer swallows the error and clears its memo, so the real navigation simply issues the request again as it normally would. A failed speculation never surfaces to the user.
Related
- Prefetching & Preloading Routes — the parent guide covering prefetch, preload, modulepreload, priority hints, and the Speculation Rules API.
- Route-Based Code Splitting — the per-route chunking that produces the dynamic imports this prefetcher warms.
- Routing Architecture & Fundamentals — the overview connecting matching, code splitting, and navigation performance.