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.

Cold dynamic import versus a preloaded module graph Two timelines for the same navigation. Without preloading, the click triggers a request for the route chunk, then a second request for its shared dependency discovered after parsing, then rendering. With modulepreload the graph is already in the module map, so the click leads straight to evaluation and render. Cold import — two serial round trips click fetch route chunk fetch shared dependency render blank until here modulepreload on hover — graph already warm chunk + imports click evaluate render no network on the critical path Preloading fetches and parses but never executes — a route the user skips costs bandwidth and nothing else.
The saving is not one request but two: the transitive import discovered after parsing is the round trip users actually feel.

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>
Choosing between modulepreload, preload and prefetch Three hint types compared across priority, whether the module graph is followed, and the situation each suits: modulepreload for imminent navigation, preload for non-module assets, prefetch for a possible future navigation. Which hint for which situation modulepreload priority: high follows the import graph parsed, not executed "about to be clicked" hover, focus, known next step preload priority: high does NOT follow imports bytes only, not parsed non-module assets fonts, hero images, CSS prefetch priority: lowest idle-time fetch cached for later "may be needed later" whole-site speculative warming
Only 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 crossorigin mismatch causes a double fetch. Module scripts are fetched in CORS mode; a hint without crossorigin is 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 pointerdown or touchstart there; 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.

Four preload symptoms and the cause behind each Four observable symptoms of an incorrect modulepreload setup — a duplicate download, no saving, an unused-preload warning, and a 404 on a hashed filename — each paired with its cause. Symptoms of a mis-wired preload hint what you see cause the file downloads twice two entries in the Network panel crossorigin missing on the hint the click still waits no saving at all hint added after the click, not on intent console warns "not used" a warning a few seconds in the heuristic fires far too eagerly a 404 for a hashed file the hint points at an old build hints hand-written instead of generated
All four are visible in the Network panel, and each points at exactly one line of the implementation.