Prefetching on Hover and Viewport Intersection

Prefetching without an intent signal is a guess. Prefetching every route on load downloads the application the splitting was meant to avoid; prefetching nothing means every navigation pays a full round-trip. The useful middle is to prefetch when the user has shown they are heading somewhere β€” which they do, measurably, a few hundred milliseconds before they click.

The two signals worth acting on are hover with a dwell threshold, and viewport intersection. Both are cheap, both are accurate, and both need bounds, or they become the problem they were meant to solve. The strategic framing is in prefetch and preload strategies for critical routes; this page is the implementation.

Root cause: navigation latency is paid at the worst moment

Without prefetching, a route transition looks like this: the user clicks, the router matches, the chunk request goes out, 200–600 ms passes, the chunk evaluates, the view renders. The entire wait sits between the click and any visible response, which is where users perceive latency most sharply.

The signals arrive earlier than the click. Pointer arrival precedes a click by roughly 200–300 ms for a decisive user. A link entering the viewport precedes a click by considerably more. Both windows are enough to absorb most of a chunk fetch.

The Window Between Intent and Click A pointer arriving on a link precedes the click by around 250 milliseconds; starting the prefetch after a short dwell threshold means the chunk is usually resolved by the time the click lands. User timeline pointer arrives dwell 80 ms β†’ prefetch starts click at ~250 ms Without prefetch fetch chunk β€” user waits 320 ms render With hover prefetch fetch chunk β€” before the click render navigation feels instant

The implementation

One module handles both signals, deduplicates requests, respects the connection, and enforces a budget.

Four Guards Before a Request A candidate prefetch is rejected if it is already fetched, over the page budget, on a slow connection, or the user has asked to save data. intent signal alreadyfetched? over thebudget? slow orsaveData? prefetch Any guard failing drops the candidate silently β€” speculation must never cost the user
// prefetch.js β€” intent-driven route prefetching with bounds
const prefetched = new Set();
const DWELL_MS = 80;         // reject pointers merely passing over a link
const MAX_PER_PAGE = 5;      // a link-dense page must not fetch everything

function allowed() {
  const c = navigator.connection;
  if (!c) return true;                                   // no signal: assume fine
  if (c.saveData) return false;                          // user asked us not to
  return !/^(slow-2g|2g|3g)$/.test(c.effectiveType);     // speculative bytes cost too much
}

function prefetch(url) {
  if (!url || prefetched.has(url) || prefetched.size >= MAX_PER_PAGE || !allowed()) return;
  prefetched.add(url);
  const link = document.createElement('link');
  link.rel = 'prefetch';      // lowest priority, idle time β€” never 'preload' here
  link.as = 'script';
  link.href = url;
  document.head.appendChild(link);
}

// ── Signal 1: hover with a dwell threshold ──
let timer;
document.addEventListener('pointerover', (event) => {
  const link = event.target.closest('a[data-chunk]');
  if (!link) return;
  timer = setTimeout(() => prefetch(link.dataset.chunk), DWELL_MS);
});
document.addEventListener('pointerout', () => clearTimeout(timer));

// ── Signal 2: touch, which has no hover to observe ──
document.addEventListener('touchstart', (event) => {
  const link = event.target.closest('a[data-chunk]');
  if (link) prefetch(link.dataset.chunk);   // ~100–300 ms before the tap completes
}, { passive: true });

// ── Signal 3: links approaching the viewport ──
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    prefetch(entry.target.dataset.chunk);
    io.unobserve(entry.target);             // one shot per link
  }
}, { rootMargin: '200px' });

export function observeLinks(root = document) {
  for (const link of root.querySelectorAll('a[data-chunk]')) io.observe(link);
}

The data-chunk attribute is the piece that needs build support: each link has to know which chunk its route maps to. Most meta-frameworks expose a route manifest for exactly this; without one, emit the mapping from the build’s chunk manifest, the same artefact used for field measurement in measuring real-user chunk loading performance.

Choosing between the two signals

They suit different surfaces, and using both indiscriminately on the same page doubles the speculative traffic.

Hover suits dense navigation: a menu, a table of links, a sidebar. Intent is strong, the window is short, and the dwell threshold filters most noise.

Viewport intersection suits content feeds: article lists, product grids, anything the user scrolls through. Intent is weaker, so the budget matters more, but the window is much longer β€” often seconds.

For a page with both, apply hover to the navigation and intersection to the content, and let the shared budget arbitrate between them.

Hover Versus Viewport as an Intent Signal Hover gives strong intent with a short lead time and low waste; viewport intersection gives weaker intent with a long lead time and higher waste risk on link-dense pages. Pick the signal from the surface Hover + dwell Viewport intent strength strong weak lead time ~200 ms seconds waste risk low high without a budget use for navigation and dense menus use for feeds and grids Both share one budget so a page cannot exceed its speculative allowance

Step-by-step verification

  1. Confirm the dwell threshold works. Sweep the pointer across a navigation bar quickly and confirm no prefetch requests appear. Rest on one link and confirm exactly one does.

  2. Confirm deduplication. Hover the same link repeatedly; there must be one request in total.

  3. Confirm the budget holds. On a page with many links, scroll to the bottom and confirm the number of prefetch requests stops at the cap.

  4. Confirm the connection opt-out. Emulate a slow connection and confirm no prefetches are issued at all.

  5. Measure the navigation improvement. Compare click-to-render with and without the prefetch. A prefetched route should render without a chunk request in the navigation.

  6. Confirm the priority. Prefetch requests must appear at lowest priority. Anything higher means a preload hint slipped in β€” the failure mode covered in fixing unused preload warnings in Chrome DevTools.

Edge cases and gotchas

Keyboard navigation. Tabbing through links produces focus events, not pointer events. Add focusin alongside hover so keyboard users get the same benefit.

Prefetching a route the user cannot access. A prefetch bypasses route guards, so a link to an unauthorised route still downloads its chunk. Filter links by permission before observing them.

Single-page navigation adding links. Links rendered after the initial page must be observed too; call the observer setup on route change or use a mutation observer.

Prefetch competing with in-flight work. Even at lowest priority, prefetching during an active fetch on a constrained connection can slow it. Delay observation until after the load event.

FAQ

How long should the hover dwell threshold be?

Between 65 and 100 milliseconds. Below that, a pointer travelling across a navigation bar triggers a prefetch for every link it passes over, which is exactly the waste the threshold exists to prevent. Above roughly 150 milliseconds you start losing the head start, because a decisive user clicks around 200 to 300 milliseconds after the pointer arrives. The window between those bounds captures deliberate hovers and rejects incidental ones.

Is viewport-based prefetching wasteful on long pages?

It is, without a budget. A page with sixty links prefetches sixty chunks as the user scrolls, which on a mobile connection is a significant amount of data for something speculative. Cap the number of prefetches per page view, prioritise links closest to the viewport, and skip prefetching entirely when the connection reports slow or data-saving mode. With those bounds it is one of the most effective loading optimisations available.

Does prefetching hurt users on metered connections?

Yes, if you ignore the signals the browser provides. Speculative bytes on a metered or slow connection cost the user money and bandwidth for code they may never need. The network information the browser exposes β€” effective connection type and the data-saving preference β€” is enough to opt those users out entirely, and doing so is both the considerate choice and the one that avoids making their experience worse.