Preloading Route Chunks with modulepreload
After reading this you will be able to warm a route’s JavaScript chunk — and every module it imports — before the user clicks, turning a lazily split route’s cold-start blank frame into an immediate render.
← Back to Route-Based Code Splitting
Prerequisites
Core Concept
Splitting a route out of the main bundle moves its cost from startup to navigation. The first click on that route now blocks on a network request for the chunk, and — because chunks import other chunks — on a second request discovered only after the first one parses. On a slow connection that is a visible blank frame precisely when the user has just expressed intent.
<link rel="modulepreload"> closes the gap. It tells the browser to fetch a module, parse it, and place it in the module map without executing it. When the dynamic import() eventually runs, the module is already resident: no request, no parse, just evaluation. Because it is a module-aware hint, the browser also processes the module’s static imports, so the whole transitive graph is warmed by a single tag rather than one tag per file.
The distinction from its neighbours matters:
rel="preload" as="script"fetches bytes into the HTTP cache. The module is not parsed, its imports are not discovered, and the credentials mode differs — which is why using it for modules often produces a double fetch.rel="prefetch"is a low-priority hint for a future navigation. It is right for “the user may go here later” and wrong for “the user is about to click this”.rel="modulepreload"is high priority, module-aware, and graph-aware. It is the correct tool for a chunk you expect to need within seconds.
The reason execution is deliberately excluded is worth internalising: a preloaded module has no side effects until something imports it, so warming a route the user never visits costs bandwidth but changes nothing. That safety is what makes aggressive preloading reasonable where prerendering a whole document would not be.
Implementation
Start from the bundler’s manifest so the chunk paths are never hand-written. Vite emits manifest.json; Rollup and esbuild can be made to emit an equivalent metafile.
// TypeScript 5.x — build a route → chunk-graph map from the bundler manifest
interface ManifestEntry {
file: string;
imports?: string[]; // manifest keys of statically imported chunks
}
type Manifest = Record<string, ManifestEntry>;
/** Every file a route needs, its transitive static imports included. */
export function chunkGraph(manifest: Manifest, entryKey: string): string[] {
const seen = new Set<string>();
const files: string[] = [];
const walk = (key: string) => {
if (seen.has(key)) return;
seen.add(key);
const entry = manifest[key];
if (!entry) return;
files.push(`/${entry.file}`);
entry.imports?.forEach(walk);
};
walk(entryKey);
return files;
}
Injecting the hints is a handful of lines, with a cache so repeated hovers do not append duplicate tags:
// TypeScript 5.x — idempotent modulepreload injection
const preloaded = new Set<string>();
export function preloadModules(urls: string[]): void {
for (const url of urls) {
if (preloaded.has(url)) continue;
preloaded.add(url);
const link = document.createElement("link");
link.rel = "modulepreload";
link.href = url;
// Same-origin modules are fetched in cors mode; matching it here avoids a
// second, separate fetch when the import finally runs.
link.crossOrigin = "anonymous";
document.head.append(link);
}
}
Wire it to intent rather than to render. Preloading every route’s graph on load defeats the purpose of splitting them:
// TypeScript 5.x — hover and focus are the cheapest reliable intent signals
const routeChunks = new Map<string, string[]>(); // built from the manifest at build time
function warmRoute(pathname: string): void {
const urls = routeChunks.get(pathname);
if (urls) preloadModules(urls);
}
document.addEventListener("pointerenter", (event) => {
const link = (event.target as HTMLElement | null)?.closest?.("a");
if (!link || link.origin !== location.origin) return;
warmRoute(link.pathname);
}, { capture: true }); // pointerenter does not bubble; capture makes delegation work
// Keyboard users never hover — focus is the equivalent signal.
document.addEventListener("focusin", (event) => {
const link = (event.target as HTMLElement | null)?.closest?.("a");
if (link && link.origin === location.origin) warmRoute(link.pathname);
});
// Respect the user's data preference before spending their bandwidth.
const connection = (navigator as { connection?: { saveData?: boolean } }).connection;
if (connection?.saveData) preloadModules([]); // effectively disable
For routes known to be next — the second step of a checkout, the only link on a landing page — put the hint in the HTML instead, where the preload scanner finds it before any JavaScript runs:
<!-- Emitted at build time for the highest-confidence next route -->
<link rel="modulepreload" href="/assets/checkout-payment.a91f.js" crossorigin>
<link rel="modulepreload" href="/assets/shared-forms.7c02.js" crossorigin>
modulepreload is module-aware; using preload for a chunk skips the graph and frequently causes a duplicate fetch.Verification
// @playwright/test v1.44 — the chunk must already be resident when the click lands
import { test, expect } from "@playwright/test";
test("hovering a link warms the route's whole chunk graph", async ({ page }) => {
await page.goto("/");
const requested: string[] = [];
page.on("request", (r) => { if (r.url().endsWith(".js")) requested.push(r.url()); });
await page.getByRole("link", { name: "Reports" }).hover();
await page.waitForTimeout(300);
// The route chunk AND its shared dependency, from one hint.
expect(requested.some((u) => u.includes("reports"))).toBe(true);
expect(requested.some((u) => u.includes("shared-charts"))).toBe(true);
const afterHover = requested.length;
await page.getByRole("link", { name: "Reports" }).click();
await expect(page.locator("[data-route='reports']")).toBeVisible();
// No new JavaScript requests: the click was served entirely from the module map.
expect(requested.length).toBe(afterHover);
});
Manually, throttle to Slow 3G and watch the Network panel: the hover should produce requests with a modulepreload initiator, and the click should produce none. If the click re-requests the same file, the two fetches are being treated as different cache entries — nearly always a crossorigin mismatch between the hint and the import.
Gotchas
- A
crossoriginmismatch causes a double fetch. Module scripts are fetched in CORS mode; a hint withoutcrossoriginis a different cache key and the browser downloads the file twice. rel="preload" as="script"is not a substitute. It skips the graph and, for modules, frequently produces the same duplicate-fetch problem.- Preloading everything on load undoes the split. If every route’s chunk is warmed at startup you have shipped one large bundle with extra requests.
- Hover is not an intent signal on touch devices. Use
pointerdownortouchstartthere; a hover listener alone leaves mobile users with the cold path. - Unused preloads produce a console warning in Chromium after a few seconds. It is advisory, but a page full of them means the heuristic is too eager.
- Hashed filenames change every build. Generate the hints from the manifest, never by hand, or the first deploy after a refactor silently preloads files that no longer exist.
FAQ
Does modulepreload execute the module? No — it fetches, parses and places it in the module map. Execution happens only when something imports it, which is what makes preloading a route the user never visits harmless.
How early should I preload? On intent, not on render. Hover, focus, or pointer-down give you a few hundred milliseconds, which is usually enough to cover the whole graph on a normal connection.
Should I preload CSS for the route too? Yes, with rel="preload" as="style" — route-level stylesheets are not part of the module graph, so modulepreload will not discover them.
What if the user never clicks? You spent the bandwidth and nothing else. That is the tradeoff, and it is why saveData and connection quality deserve a check before warming aggressively.
Does this help the initial page load? Only indirectly: for chunks needed during the first render, a build-time hint lets the preload scanner start them earlier than the module graph would have been discovered.
Related
- Route-Based Code Splitting — the split that creates the cold-start cost this technique removes.
- Dynamic Import per Route in a SPA — the lazy loading pattern being warmed.
- Speculation Rules for Route Prerendering — the document-level equivalent for sites whose routes are real navigations.