How to Prefetch Route Chunks on Hover and Viewport
This guide builds on Dynamic Imports and Route-Based Splitting within JavaScript Bundle Optimization & Code Splitting. Route-based splitting shrinks the initial bundle by moving each route's code into its own chunk — and in exchange, every first visit to a route waits for that chunk to download, parse and evaluate. On a mobile connection that is commonly 200–600ms added to the transition, enough to make a fast SPA feel slower than a server-rendered site.
Prefetching closes the gap by fetching the chunk before the user clicks. The art is in the trigger: prefetch too eagerly (every link on the page at load) and you waste bandwidth and compete with the current page's resources; prefetch too late (on click) and you gain nothing. The useful triggers are viewport visibility for likely next steps, hover or focus intent for desktop users, and pointerdown as a last-moment head start on touch devices.
Rapid Diagnosis
- Measure transition time for first visits to a route. If it is dominated by a chunk request that starts on click, prefetching will help.
- Check what your framework already does. Next.js
<Link>, Nuxt<NuxtLink>, SvelteKit and Remix all have built-in prefetch behaviour; custom routers usually have none. - Look at bandwidth contention. If prefetches fire during the initial load, they can delay the current page's LCP resources.
- Check data saver and connection type. Prefetching on
saveDataor 2G connections spends users' data on guesses.
Root Cause Analysis
1. Chunks fetched only on demand. Lazy routes trade initial size for transition latency; without prefetch, the trade is paid in full on every first visit.
2. Over-eager prefetching. Prefetching every link at load fills the network queue with low-value requests during the page's most critical seconds.
3. Prefetch without the dependency graph. Prefetching only the route's entry chunk leaves its imported chunks to load after the click — a smaller waterfall, but still a waterfall.
4. Data not prefetched. On data-heavy routes, the API call dominates the transition; prefetching only code leaves most of the latency in place.
Step-by-Step Resolution
1. Build a single prefetch function with network guards
// prefetch.js
const done = new Set();
export function prefetchRoute(load) {
if (done.has(load)) return;
const c = navigator.connection;
if (c?.saveData || /2g/.test(c?.effectiveType ?? '')) return; // respect data saver
done.add(load);
load().catch(() => done.delete(load)); // retry next time if it failed
}
// trade-off: calling the route's import() function triggers the bundler's
// preload of its dependency graph at normal priority. For viewport prefetching
// of many links, use <link rel="prefetch"> (idle priority) instead — step 3.
Expected outcome: one entry point for all prefetching, with consistent guards.
2. Prefetch on intent: hover, focus and pointerdown
import { prefetchRoute } from './prefetch.js';
const routes = { '/pricing': () => import('./pages/Pricing.jsx'), '/docs': () => import('./pages/Docs.jsx') };
function onIntent(e) {
const a = e.target.closest('a[href]');
const load = a && routes[new URL(a.href).pathname];
if (load) prefetchRoute(load);
}
let hoverTimer;
document.addEventListener('mouseover', (e) => { clearTimeout(hoverTimer); hoverTimer = setTimeout(() => onIntent(e), 65); });
document.addEventListener('focusin', onIntent);
document.addEventListener('pointerdown', onIntent, { passive: true });
// trade-off: a 65ms hover delay filters out cursor fly-overs but costs a little
// head start. Raise it on pages with dense link lists (mega-menus) to avoid
// prefetching every item the cursor crosses.
Expected outcome: desktop users typically gain 150–400ms of head start; touch users get a smaller but free head start from pointerdown.
3. Prefetch likely next routes in the viewport at idle priority
For primary calls to action visible on the page, use IntersectionObserver and low-priority <link rel="prefetch"> for the route's files from the build manifest.
const io = new IntersectionObserver((entries) => {
for (const { isIntersecting, target } of entries) {
if (!isIntersecting) continue;
io.unobserve(target);
requestIdleCallback(() => {
for (const href of routeFiles(target.pathname)) { // from the Vite/webpack manifest
const l = document.createElement('link'); l.rel = 'prefetch'; l.href = href; document.head.append(l);
}
});
}
}, { rootMargin: '200px' });
document.querySelectorAll('a[data-prefetch]').forEach((a) => io.observe(a));
// trade-off: limit viewport prefetching to links you mark explicitly. On a
// listing page with 60 product links, prefetching all of them wastes far more
// than it saves — prefetch the product route chunk once, not per link.
Expected outcome: the next likely route's code is in the HTTP cache before the user decides to go there.
4. Prefetch data alongside code for data-heavy routes
On hover intent, also warm the route's primary API response (with a short-lived cache in your data layer), so the click finds both code and data ready.
Verification
In DevTools, hover a link and watch the Network panel: the route chunk (and its dependencies) should be requested once, then served from cache on click. Measure route transition time with User Timing marks before and after, as in tracking route transition time with User Timing; the code phase should drop to near zero for prefetched routes. Check that the current page's LCP did not regress — prefetches should start after it.
Worked Example: A Documentation Site
A docs site split each section into its own chunk. Field data showed first visits to a section taking a median 520ms to render on mobile, of which 340ms was chunk download. The team added hover/focus/pointerdown prefetching for sidebar links and idle-time viewport prefetching for the "Next page" link at the bottom of each article. Prefetches were limited to one chunk per section and disabled under data saver. Median transition time on first section visits fell to 210ms, while total bytes per session rose by only 4% — most prefetches were used, because sidebar hovers and "next page" visibility are strong predictors of the next click.
Common Mistakes
- Prefetching during initial load. Wait until after
load(or LCP) before any speculative fetch. - Using
rel="preload"for speculative chunks. Preload is high priority and for the current page; prefetch is idle priority and for the next one. - Prefetching per link instead of per route. Many links share one route chunk; deduplicate by chunk.
- Ignoring logged-out vs logged-in differences. Prefetching authenticated routes for logged-out users wastes requests that will redirect.
Measuring Prefetch Effectiveness
Prefetching is a bet, so measure the payoff. Record two numbers per session in RUM: how many route chunks were prefetched, and how many of those were subsequently used by a navigation. A hit rate above roughly 50% means triggers are well chosen; below 20% means you are mostly spending users' bandwidth on guesses, and triggers should be narrowed. Also compare transition time for navigations that hit a prefetched chunk against those that did not — the difference is the value of each successful prefetch, which lets you weigh it against the bytes spent on misses.
FAQ
How is this different from Speculation Rules?
Speculation Rules prefetch or prerender whole documents for multi-page navigations. Route-chunk prefetching warms JavaScript for client-side navigations within an SPA. They solve the same problem at different layers; an SPA benefits from chunk prefetching, a multi-page site from speculation rules for instant navigations.
Does prefetching affect INP?
Downloading in the background does not block the main thread. Evaluating a prefetched module does, so prefer fetching (via <link rel="prefetch">) over executing import() for speculative work, and keep heavy top-level code out of route modules so evaluating them on click stays cheap.
What about Quicklink-style libraries?
Libraries like Quicklink prefetch links in the viewport during idle time with sensible guards, mainly for multi-page sites. They are a good starting point; for SPAs, framework-integrated prefetching that knows your route-to-chunk mapping is more precise. See viewport prefetching with Quicklink.
Related
- Fixing waterfalls from nested dynamic imports — the chains prefetching must cover.
- Controlling Vite modulepreload for dynamic imports — how dependency preloads interact with prefetching.
- Code-splitting Vue Router routes — creating the chunks to prefetch.