Preloading Dynamic Chunks with modulepreload
Code splitting trades a smaller initial bundle for a network round-trip when the lazy chunk finally loads — and on a slow connection that round-trip is a visible stall. rel="modulepreload" fetches and parses the chunk ahead of time, so it is warm in the cache the moment the route mounts. This guide uses it deliberately without preloading everything. It builds on code-splitting strategies for large applications; the parent overview is Core Concepts of Modern Bundling.
The problem exists because a dynamic import() is a promise that does not settle until the browser has done four things in sequence: resolved the specifier to a URL, opened a connection, transferred the bytes, and compiled the module. When you split a route into its own chunk, you move all four of those steps out of initial load and into the exact moment the user clicks. That is the whole point of splitting — but it also means the click now has a dependency it did not have before. On a fast connection the four steps finish in a few dozen milliseconds and nobody notices. On a congested mobile link with 400 ms of round-trip latency, the connection setup alone can cost more than the chunk transfer, and the route sits blank behind a spinner while the network works. Preloading does not make the chunk smaller or the connection faster; it moves the work earlier, into a window where the user is not waiting for it.
Where this sits in the pipeline matters. The bundler has already decided the chunk boundaries and hashed the filenames by the time preloading is relevant — preloading is a delivery concern, not a bundling one. It operates on the emitted artifacts: the .js files in dist/assets and the manifest.json that maps source modules to their hashed output. Nothing here changes what the bundler produces; it changes when the browser asks for it. That separation is why the same technique works identically whether Vite, Rollup, or esbuild produced the chunk — they all emit ES modules with content-hashed names, and modulepreload is a browser primitive that operates on the URL, indifferent to which tool wrote it.
Prerequisites & reproducible setup
# Vite 5.4.x, Node 20+, React Router (any lazy-route setup)
npm create vite@latest preload-demo -- --template react-ts
cd preload-demo && npm install react-router-dom
# A route lazy-loaded with dynamic import creates a separate chunk.
// A lazy route — this import() is what produces the separate chunk.
const Reports = lazy(() => import('./routes/Reports'));
Vite already injects modulepreload links for a lazy chunk’s static dependencies; the gap this fills is preloading the lazy chunk itself before the user navigates.
To understand what “static dependencies” means here, look at what Vite does at build time. When it sees import('./routes/Reports'), it emits Reports-a1b2c3.js as its own chunk and then walks that chunk’s static import statements — the shared vendor code, the component library, anything the route pulls in unconditionally. Vite knows those files will be needed the instant the route’s code runs, so it writes a <link rel="modulepreload"> for each of them into the HTML that loads the entry, and it wires up a small runtime helper (__vitePreload) around the dynamic import so that when the import fires, the dependency links are already resolved. What Vite deliberately does not do is preload the lazy chunk itself, because doing so would fetch every split route on first paint and undo the split. That decision — leaving the lazy chunk cold until navigation — is correct as a default and is exactly the seam this guide works in: you keep the chunk cold until there is a real signal the user is heading toward it, then warm it just in time.
How it works under the hood
rel="modulepreload" is not just a cache hint like rel="preload" — it is module-aware. When the browser encounters it, it fetches the resource, then parses it as an ES module, and crucially it recurses into that module’s static import graph and fetches those dependencies too. It also compiles the module and stores the result in the module map, keyed by the resolved URL. That last detail is what makes the eventual import() cheap: when the route finally runs its dynamic import, the browser looks up the URL in the module map, finds an already-fetched, already-compiled entry, and resolves the promise without touching the network. A plain rel="preload" would put the bytes in the HTTP cache but leave them uncompiled, so the import still pays parse and compile cost on the critical path; modulepreload pays that cost up front, during idle time.
The deduplication that the programmatic approach relies on happens at the same layer. The module map is keyed by the fully resolved URL, so two import() calls for the same specifier — one on hover, one on navigation — resolve to the same map entry. The first call initiates the fetch and stores a pending promise; the second call finds that pending promise and awaits it rather than starting a second request. This is why hovering and then clicking produces exactly one network request even though your code invoked import() twice. It also means you cannot “double-fetch” a chunk by mistake through this path — the browser collapses concurrent and sequential requests for the same module URL automatically.
The hashing is what forces discipline on the static-link approach. Vite names each chunk with a content hash — Reports-a1b2c3.js — computed from the chunk’s bytes, so any change to the route’s source or its dependencies produces a new hash and a new filename. A hardcoded href in your HTML is therefore correct for exactly one build. The durable way to write a static link is to read dist/.vite/manifest.json, which maps the source module (src/routes/Reports.tsx) to its current file field, and emit the link from that mapping during your HTML build step. The manifest is the single source of truth the bundler already maintains for precisely this kind of artifact-to-URL lookup.
Diagnosis workflow
- Confirm the stall. In DevTools Network, throttle to Slow 3G, click the lazy route, and watch the chunk request block the render.
- Decide the trigger. Preload on a strong intent signal — hovering the link, the route becoming likely, or browser idle — not on initial load, or you defeat the split.
- Pick the mechanism. A static
<link rel="modulepreload">for a known-next chunk, or a programmaticimport()on hover that the browser dedupes with the eventual navigation.
Take each step deliberately rather than as a checklist. Confirming the stall first is not busywork — it tells you whether preloading is even the right lever. Open DevTools, set throttling to Slow 3G, disable the cache, and click the lazy route while watching the waterfall. If the chunk request appears at click time and the route’s first paint waits on it, you have a preloading problem. If instead the paint waits on a data fetch that fires after the component mounts, no amount of chunk preloading will help and you should be looking at data prefetching instead. Measure the actual blocking resource before you assume it is the chunk.
Deciding the trigger is the real design decision, because it sets the trade-off between wasted bytes and hidden latency. Hover and focus are strong signals on desktop — a user whose pointer is on a link is very likely to click it, and the gap between hover and click is usually 100–300 ms, enough to cover a chunk fetch on a decent connection. Browser idle (requestIdleCallback) is the right trigger for a route that is probably next but not certain, such as the second step of a wizard. Initial load is almost never the right trigger for a lazy chunk, because preloading on load is functionally identical to not splitting the chunk at all — you pay the bytes up front either way.
Picking the mechanism follows from the trigger. If the next step is genuinely known and singular — a login page that always leads to the dashboard — a static <link rel="modulepreload"> emitted from the manifest is the simplest correct tool, with no JavaScript involved. If the next step depends on user behavior, a programmatic import() fired on the intent signal is more flexible and, thanks to module-map deduplication, costs nothing extra when the navigation eventually happens.
Annotated solution config
// PreloadLink.tsx — preload a lazy route's chunk on hover/focus (Vite 5.4.x).
import { Link, LinkProps } from 'react-router-dom';
// Map route paths to their dynamic import so hover can warm the chunk.
const preloaders: Record<string, () => Promise<unknown>> = {
'/reports': () => import('./routes/Reports'),
};
export function PreloadLink({ to, ...rest }: LinkProps) {
const warm = () => preloaders[String(to)]?.();
// The browser dedupes this import() with the one the router runs on navigation,
// so hovering starts the fetch and clicking reuses it.
return <Link to={to} onMouseEnter={warm} onFocus={warm} {...rest} />;
}
<!-- Or, for a known single next step, a static link in <head>. Vite emits
the correct hashed filename via its manifest; reference that at build time. -->
<link rel="modulepreload" href="/assets/reports-a1b2c3.js" />
The preloaders map is deliberately explicit rather than derived from the router config. You want the same import('./routes/Reports') expression in both the map and the route definition so the bundler treats them as one dependency and the browser dedupes them at runtime; if you generated the import dynamically from a string path, the bundler could not statically see the target and would either fail to split it or split it differently. Keeping the literal import in both places is the price of the deduplication guarantee. Binding to both onMouseEnter and onFocus covers pointer and keyboard users; the ?.() guard means a link with no registered preloader simply behaves like a normal Link, so you can adopt this incrementally, one route at a time.
For the “probably next” case, prefer an idle-triggered warm-up over an eager one so the preload never competes with resources the current view still needs:
// idlePreload.ts — warm a likely-next chunk during browser idle (Vite 5.4.x).
export function idlePreload(load: () => Promise<unknown>) {
const ric = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 200));
// Fires only after the browser has finished the current view's critical work,
// so the split chunk stays off the initial critical path.
ric(() => { void load(); });
}
// Usage: after the dashboard's own render settles, warm the reports route.
idlePreload(() => import('./routes/Reports'));
To emit the static link from the manifest instead of hardcoding the hash, read the manifest in your HTML build step and look up the source module’s current output:
// build-preload-link.mjs — resolve the hashed chunk from Vite's manifest (Vite 5.4.x).
import { readFileSync } from 'node:fs';
const manifest = JSON.parse(readFileSync('dist/.vite/manifest.json', 'utf8'));
// The key is the source path; `.file` is the current content-hashed output.
const file = manifest['src/routes/Reports.tsx'].file;
const link = `<link rel="modulepreload" href="/${file}" />`;
console.log(link); // inject into <head> at build time — never a hand-typed hash.
import() on hover and on navigation collapses to one request the browser reuses.Verification
# Vite 5.4.x — build and preview, then inspect the Network panel.
npx vite build && npx vite preview
# Throttle to Slow 3G. Hover the link: the chunk request starts.
# Click: the route mounts with no new blocking request.
On a throttled connection, hovering the link starts the chunk request early; the subsequent click mounts the route with the chunk already in cache, so the Network panel shows no blocking fetch at navigation time. Without the preload, the same click shows a fresh, blocking request.
Read the waterfall carefully, because the failure modes look similar at a glance. The signal that the preload worked is a chunk request whose initiator is the hover handler and whose timing sits before the navigation, followed by a navigation that mounts with no new request for that chunk. If you see two separate requests for the same chunk URL, deduplication failed — usually because the two import() expressions do not textually match and the bundler emitted two chunks, or because you preloaded a stale hash and the navigation fetched the current one. If you see the hover request still in flight when the click lands, the connection is slower than the hover-to-click gap; that is not a bug, it is the preload doing partial work, and the route will still mount faster than with no preload at all. To make the test honest, always disable the HTTP cache in DevTools between runs, or a warm cache will hide whether the preload is actually responsible for the speedup.
You can also assert the behavior programmatically in a smoke test rather than relying on eyeballing the panel:
// preload.spec.ts — assert hover warms the chunk before click (Playwright 1.4x).
import { test, expect } from '@playwright/test';
test('hover warms the reports chunk', async ({ page }) => {
const requests: string[] = [];
page.on('request', (r) => requests.push(r.url()));
await page.goto('/');
await page.hover('a[href="/reports"]'); // intent signal
await page.waitForRequest(/reports-.*\.js/); // chunk fetched on hover
const before = requests.filter((u) => /reports-.*\.js/.test(u)).length;
await page.click('a[href="/reports"]'); // navigation reuses it
expect(before).toBe(1); // exactly one fetch total
});
Gotchas & edge cases
- Over-preloading. Preloading chunks the user never visits wastes bandwidth and can compete with critical requests; preload only likely-next routes. The symptom is subtle: the page feels snappy in testing but your bytes-transferred metric climbs and mobile users on metered connections pay for routes they never open. The root cause is treating preload as free — it is not, it is deferred cost. The fix is to preload from a real intent signal (hover, focus, idle) rather than on load, and the way to confirm you got it right is to check that the initial-load waterfall is unchanged after adding the preload; if first paint now pulls extra chunks, you have re-inflated exactly what you split out.
- Hardcoded hashes. A static
hrefwith a content hash breaks on the next build; read the filename from Vite’smanifest.jsonat build time. The symptom is a 404 for the preload that appears only after a deploy, because the old hash no longer exists — and because a failedmodulepreloadfails silently, the only visible effect is that navigation is slow again with no error in the console. The root cause is treating a content hash as a stable identifier. The fix is the manifest lookup shown above; confirm it by diffing the emitted link across two builds and checking the hash actually changed with the source. - Safari support.
modulepreloadhas had partial Safari support; a programmaticimport()on intent is the more portable trigger. The symptom is that the same page is warm-on-hover in Chrome but cold-on-click in older Safari, because the declarative link is ignored there. The root cause is relying on a link relation the engine does not honor. The fix is to drive the warm-up throughimport()in JavaScript, which every module-supporting engine executes identically; confirm by running the Playwright assertion above in a WebKit project, not just Chromium. - Mobile triggers.
onMouseEnternever fires on touch;touchstartcan fire during scroll — gate mobile preloads on a deliberate signal. The symptom on touch devices is either no preload benefit at all (hover never fires) or preloads firing constantly as the user scrolls a list of links, which is the over-preloading failure by another route. The root cause is that touch has no true hover equivalent. The fix is to use a more intentional signal —pointerdown, or the link entering the viewport viaIntersectionObserverfor above-the-fold primary actions — and to confirm on a real device that scrolling a list does not trigger a burst of chunk fetches.
When not to use this
Preloading is worthless when the chunk is tiny or when the connection is fast, because the round-trip you are hiding was never expensive. If your lazy chunk is a few kilobytes gzipped and your users are on broadband, the hover-to-click preload saves single-digit milliseconds and adds code you now have to maintain — skip it. It is also the wrong tool when the real bottleneck is data rather than code: a route whose chunk loads in 40 ms but whose first meaningful paint waits 800 ms on an API call will feel exactly as slow with preloading as without, and the effort belongs on prefetching that data, not the chunk. Finally, reconsider preloading a route that most users never reach; the maintenance and bandwidth cost is real and the payoff only materializes for the fraction who navigate there. Split the chunk, yes — but leave it cold and let the navigation pay for it.
Comparison with prefetch and preload
Three link relations sound interchangeable and are not. rel="modulepreload" fetches, parses, compiles, and recurses into the ES module graph at high priority, storing the result in the module map — it is the only one of the three that makes a later import() resolve without any main-thread work. rel="preload" with as="script" fetches the bytes at high priority into the HTTP cache but does not compile them or follow module imports, so the eventual import still pays parse and compile cost, and for a classic script it can even execute at the wrong time. rel="prefetch" fetches at the lowest priority as a hint for a future navigation, and browsers will drop it under load — it is right for a next-page resource you might need in ten seconds, wrong for a chunk you need in 200 ms. For a route split by import(), modulepreload is the correct relation because it aligns the browser’s cache state with the module system your code actually uses; the other two leave a gap between “bytes on disk” and “module ready to run” that shows up as jank at navigation.
Performance considerations
The metric preloading moves is the time between the intent and the mounted route, not any load-time number — and that is the metric to guard in CI. Preloading has no legitimate effect on Largest Contentful Paint or Total Blocking Time for the initial view; if adding a preload changes those, you are preloading on load and should stop. Watch three things instead. First, bytes transferred during idle: a healthy preload adds requests after first paint, never before it. Second, request contention: a preload fired while a critical fetch is in flight can steal bandwidth, so idle triggers are safer than immediate ones on slow links. Third, the module compile cost, which modulepreload pays during idle — this is a feature on a warm CPU but can itself cause a small jank on a cheap phone, so preload the one likely-next chunk rather than a batch. The right mental model is that preloading spends idle time and idle bandwidth to buy navigation latency; keep the spend proportional to how likely the navigation is.
CI integration
Preloading regressions are invisible until a user hits a slow connection, so pin the behavior with an automated check that runs on every build. The Playwright assertion above — hover fetches the chunk, click reuses it, exactly one request — is the core guard; run it in both a Chromium and a WebKit project so the Safari fallback is actually exercised. Complement it with a build-time assertion that the preload links in your HTML resolve to files that exist in dist, which catches the stale-hash failure before it reaches production: after vite build, parse the emitted HTML, extract every modulepreload href, and fail the build if any of them is absent from the manifest’s output set. Together these two checks cover the two ways preloading silently rots — a dead hash after a rebuild, and a mechanism that stopped deduplicating — and both fail loudly in CI rather than quietly in the field.
Related
- Code-splitting strategies for large applications — the parent cluster on splitting itself.
- Dynamic import code-splitting patterns for React — the lazy-route patterns this preloads.
- Route-based code splitting with Vue Router — the same preloading idea in Vue.