How to Use modulepreload for ES Module Graphs
This guide covers the module-specific hint within Resource Hints & Early Hints, part of Advanced Caching Strategies & CDN Architecture. ES modules are loaded as a graph: the browser fetches the entry module, parses it to find its import statements, fetches those, parses them, and so on. Each level of the graph is discovered only after the previous level arrives, producing a waterfall that costs one round trip per level of depth.
<link rel="modulepreload"> tells the browser about modules before they are imported. Unlike a plain preload as="script", it fetches the module with the correct request mode and credentials for module scripts, stores it in the module map, and may parse and compile it ahead of time. Listing the startup graph's modules collapses the waterfall into one parallel batch.
Rapid Diagnosis
- Look for staircases in the Network panel's JavaScript requests: each module starting only after another finishes.
- Check hint types.
rel="preload" as="script"for module scripts can result in a second request (mode mismatch); modules should usemodulepreload. - Check bundler output. Vite emits modulepreload links automatically; custom setups and unbundled sites may not.
- Count hints. Dozens of modulepreloads can crowd out the LCP image and CSS.
Root Cause Analysis
1. Discovery waterfalls. Imports are only known after parsing the importer.
2. Wrong hint type. Plain preload does not match the module request's mode, so the module is fetched again.
3. Over-preloading. Preloading the entire graph, including rarely used modules, wastes bandwidth during load.
4. Missing hints for dynamic routes. Dynamically imported chunks and their dependencies load in their own waterfall when triggered.
Step-by-Step Resolution
1. Preload the startup graph's modules
<script type="module" src="/assets/main.3c1f.js"></script>
<link rel="modulepreload" href="/assets/router.8a2b.js">
<link rel="modulepreload" href="/assets/landing-view.51e0.js">
<!-- trade-off: browsers may or may not fetch dependencies of a modulepreloaded
module automatically; list every module you need early rather than relying
on recursive preloading. -->
Expected outcome: all startup modules download in parallel with the entry.
2. Let the bundler generate hints
Vite and other modern tools emit modulepreload links for static imports and preload dependencies of dynamic imports; tune them rather than hand-writing lists — see controlling Vite modulepreload for dynamic imports.
3. Exclude non-critical modules
Modules needed only after interaction (editors, charts, dialogs) should not be preloaded at startup; prefetch them on intent instead.
4. Add crossorigin for cross-origin modules
Module scripts are CORS requests; modulepreload uses CORS by default, but if modules come from another origin, that origin must send CORS headers, and credentials modes must match.
Verification
Record a throttled trace: startup modules should start together shortly after HTML parsing begins, with no staircase. Each module should be fetched once. Compare time to first render (or LCP for client-rendered pages) before and after; flattening a three-level waterfall saves roughly two round trips — 200–400ms on mobile.
Worked Example: An Unbundled Admin App
An internal admin app served native ES modules without a bundler. Its startup graph was five levels deep, and on a remote office's high-latency connection the app took 2.4 seconds to render. Generating modulepreload links for the 22 startup modules at deploy time (by walking the import graph) collapsed loading into one batch; render time dropped to 1.1 seconds. Lazy-loaded admin panels were left out of the list and prefetched when their navigation items were hovered.
Generating Hints From the Import Graph
Hand-maintained preload lists drift as code changes. Generate them: bundlers do it from their manifests, and for unbundled sites a deploy step can parse each entry's imports recursively (with tools like es-module-lexer) and emit the closure as link tags or HTTP Link headers. Recompute on every deploy and cache the result per entry. For server-rendered apps, emit per-route lists so each page preloads only the modules its route needs, rather than a global list covering every route.
Measuring the Waterfall Before and After
Quantify the problem before adding hints. From Resource Timing, compute for each module request the gap between the page's navigation start and the request's startTime, and group modules by import depth (entry = 0, its imports = 1, and so on). Without preloading, start times step up by roughly one round trip per level; with preloading, all levels start within a few milliseconds of each other. Sending the deepest level's start time as a RUM metric gives a simple field indicator: if it creeps up after a release, a new import chain was added outside the preloaded set. Pair it with the bundle analyser's import graph to find which module introduced the extra level.
Common Mistakes
- Using
preload as="script"for modules. The response may not be reused. - Preloading lazy routes at startup. Wastes bandwidth when users never visit them.
- Hand-maintained lists. They go stale after refactors; generate them.
- Forgetting hashed filenames. Hints must point at the deployed hashed URLs, not source paths.
Edge Cases
Import maps. Bare specifiers resolved through import maps can be preloaded using their resolved URLs; some browsers also resolve specifiers in modulepreload through the map.
Service workers. Preloaded modules go through the worker's fetch handler like other requests; cache-first strategies make repeat loads instant.
Early Hints. modulepreload can be sent in 103 responses, starting module downloads during server think time.
Old browsers. modulepreload is supported in current Chromium, Firefox and Safari; Vite ships a tiny polyfill for older engines if needed.
FAQ
Does modulepreload execute the module?
No. It fetches and may compile the module, but evaluation happens only when the module is imported. Side effects do not run early.
Is modulepreload useful with a bundler?
Yes — bundles still split into chunks that import each other. Bundlers emit modulepreload for those relationships; check the output rather than adding manual hints.
How many modulepreloads are too many?
When they delay critical resources. On mobile, more than about 10–15 startup chunks suggests the chunking is too fine; fix chunking rather than adding hints.
Can modulepreload be used for workers?
Module workers load their own graphs in the worker context; modulepreload in the document does not populate the worker's module map. Preloading worker modules has limited benefit.
Does modulepreload help INP?
Indirectly: earlier compilation can reduce work during startup, and faster startup means fewer long tasks overlapping early interactions.
What about prefetch for future routes?
Use rel="prefetch" (idle priority) for modules likely needed on the next navigation, and modulepreload for modules needed now.
Does modulepreload affect caching?
Preloaded modules are cached by the HTTP cache like any other request, under their own Cache-Control headers. With content-hashed filenames and immutable caching, repeat visits load them from cache and the hints cost nothing.
Should modulepreload hints go in the HTML or in Link headers?
Both work. HTML link elements are easiest to generate per page; HTTP Link headers can be sent earlier, including in 103 Early Hints, which lets module downloads start before the HTML is even ready.
Related
- Using import maps for unbundled production — where modulepreload is essential.
- Fixing waterfalls from nested dynamic imports — waterfalls in lazy code.
- Preload vs preconnect: when to use each — the general hint decision.