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.

Which intent signals exist on which input type Five intent signals compared across mouse input and touch or keyboard input. Hover-based signals do not exist on touch devices, which is why pointerdown and focusin are needed for full coverage. Intent signals across input types mouse touch and keyboard hover start the pointer crosses the link does not exist sustained hover ~150ms deliberate does not exist pointerdown just before the click fires on touch too focusin only via keyboard the keyboard equivalent of hover visibility in viewport weak on both weak on both
A hover-only implementation leaves every touch and keyboard user on the cold path.

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
});
Five guards a hover prefetcher must have Five guards for a hover-triggered prefetcher — same-origin filtering, a dedupe set, a data-saver check, cancellation when the pointer leaves early, and a concurrency cap — each with the consequence of omitting it. Guards a hover prefetcher needs guard without it same-origin check skip external links you prefetch other people's sites already-prefetched set never fetch twice one request per hover, repeatedly saveData check respect the data preference you spend metered bandwidth cancel on early leave a 40ms brush is not intent most fetches are wasted concurrency cap at most a few in flight a dense list floods the connection
Without the fourth and fifth guards a dense listing page turns one scroll into dozens of speculative requests.

Gotchas

  • A cursor sweeping across a dense navigation bar fires mouseover on 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 pointerdown and 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.
What to warm when a link is hovered Five candidate resources to prefetch on hover: the route chunk and its transitive imports always, data and stylesheets usually, and below-the-fold images never. What to prefetch on hover resource worth it? the route's JavaScript chunk the largest blocking cost yes, always its transitive imports discovered only after parsing yes — that is the second round trip the route's data may be user-specific often, but watch cache correctness the route's CSS not in the module graph yes, with a separate preload images below the fold large and rarely needed no
The second row is where the real saving is — the import discovered after parsing is the round trip users feel.

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.