modulepreload & ES Module Loading
ES modules changed the browser’s fetch scheduling problem in a way classic scripts never posed: the loader cannot know what to download next until it has parsed what already arrived. A module graph four levels deep costs four sequential network round trips, and neither the preload scanner nor a plain preload hint can shortcut the walk — the scanner never sees import statements, and a classic as="script" preload fetches with the wrong CORS mode and skips module processing entirely. <link rel="modulepreload"> is the purpose-built answer: it fetches a module, parses and compiles it, and registers the result in the document’s module map so the eventual import resolves without touching the network.
This guide covers how module loading differs from classic script loading inside the scheduler, exactly what modulepreload does beyond a byte-level preload, when to emit hints for direct and transitive dependencies, how Vite and Rollup pipelines generate them, how import maps change the rules, and how to verify the whole arrangement in DevTools without introducing duplicate fetches.
How ES module loading differs from classic scripts
A classic <script src> is a single opaque fetch: the browser downloads one file, executes it, done. A <script type="module"> is the root of a graph, and three scheduler-relevant behaviours fall out of that.
Deferred by default
Module scripts never block the parser. The HTML spec gives type="module" the semantics of defer — fetch and compile proceed in parallel with parsing, and evaluation waits until the document has been parsed (add async to evaluate as soon as the graph is ready instead). This means a module entry point discovered early in <head> does not delay first paint the way a blocking classic script would, but it also means the browser feels free to schedule module fetches at High rather than Highest fetch priority, behind render-blocking stylesheets.
Always CORS-mode fetches
Every module request — the entry and every dependency — is issued in CORS mode. With no crossorigin attribute the credentials mode is same-origin; crossorigin="use-credentials" switches it to include. Classic scripts without crossorigin fetch in no-CORS mode instead. The practical consequence: any warming mechanism whose request mode differs from the module loader’s CORS mode produces a cache entry the loader refuses to reuse, and the module is fetched twice.
Dependency discovery is serialized by parsing
The loader learns about import './chart.js' only after the importing module’s bytes have arrived and been parsed. Each level of the static import graph therefore costs a full network round trip before the next level can even be requested. The module map — the per-document registry keyed by resolved URL — deduplicates modules imported from multiple parents, but it does nothing to parallelize discovery. On an 80 ms RTT connection, a graph of depth four spends at least 320 ms in pure sequential discovery before evaluation can start, regardless of bandwidth.
Engine differences
The core algorithm is specified, but the engines differ at the edges that matter for hint placement:
| Behaviour | Chromium (Blink) | Safari (WebKit) | Firefox (Gecko) |
|---|---|---|---|
modulepreload support |
Chrome 66 (2018) | Safari 17 (2023) | Firefox 115 (2023) |
| Preloads declared dependencies (spec-optional) | No — hinted URL only | No | No |
| Priority of modulepreload fetch | High | High | High |
as values beyond script (e.g. worker) |
Supported | Not supported | Not supported |
| Compile timing after preload fetch | Parse + compile off main thread on arrival | Parse deferred until first import in some versions | Parse + compile on arrival |
| Bytecode cache reuse on repeat visits | V8 code cache after second load | Limited | JS bytecode cache |
The two rows worth internalizing: no engine walks the dependency graph for you, so transitive modules need their own hints; and the long Firefox/Safari gap (2018–2023) is why bundlers still ship a polyfill path — covered under bundler behaviour below.
What modulepreload does beyond preload
rel="preload" as="script" stores response bytes in the typed preload cache and stops. rel="modulepreload" runs most of the module pipeline ahead of demand:
- Fetch the module in CORS mode with the destination
script(or the value ofas), at High priority. - Parse and compile the source into a module record, generally off the main thread.
- Populate the module map keyed by the resolved URL, so a later
import— static or dynamic — resolves instantly against the map instead of dispatching a network request.
The spec additionally allows the user agent to fetch the module’s declared dependencies as part of handling the hint. No shipping engine implements that optional step, which is the single most common misunderstanding about this hint: emitting one modulepreload for the entry chunk warms exactly one file, and the loader still discovers import statements level by level for everything you did not hint.
The diagram below contrasts the two schedules for a graph where entry.js statically imports render.js and state.js, and render.js imports vendor.js.
Spec & API reference
Attribute semantics
| Attribute | Values | Semantics on rel="modulepreload" |
|---|---|---|
href |
Resolved URL | The module to warm. Must be a URL, never a bare import-map specifier |
as |
script (default), worker, serviceworker, sharedworker, audioworklet, paintworklet |
Sets the fetch destination so the request matches the eventual consumer; only Chromium honours non-script values |
crossorigin |
absent, anonymous, use-credentials |
Absent and anonymous both yield credentials mode same-origin; use-credentials yields include. Must mirror the consuming <script type="module"> |
integrity |
SRI hash | Must byte-match the value on the consuming element, or the preloaded entry is discarded and refetched |
fetchpriority |
high, low, auto |
Nudges the High default up or down within the scheduler; useful to demote speculative module warm-ups |
referrerpolicy |
Standard policy values | Applied to the preload fetch; mismatch does not break map reuse |
Two contrasts with plain preload worth stating explicitly: as is optional (the destination defaults to script), and the result lands in the module map, not merely the typed preload cache — so the warm-up survives being consumed by import() at any later point in the document’s lifetime, with no three-second unused window forcing a refetch.
Browser support matrix
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
rel="modulepreload" |
66 / 79 | 115 (Jul 2023) | 17.0 (Sep 2023) |
as="worker" on modulepreload |
66 / 79 | Not supported | Not supported |
fetchpriority on the hint |
101 / 101 | 132 | 17.2 |
Import maps (<script type="importmap">) |
89 / 89 | 108 | 16.4 |
<script type="module"> itself |
61 / 79 | 60 | 10.1 |
The support history explains a lot of production code you will encounter: for roughly five years (Chrome 66 in mid-2018 until Firefox 115 and Safari 17 in 2023), modulepreload was Chromium-only. Gecko and WebKit silently ignored the hint — harmless, but it meant Firefox and Safari users always paid the full sequential discovery walk. Bundlers responded by shipping a JavaScript polyfill that fetches the hinted URLs manually in those engines, and that polyfill still ships by default today for the residual base of older WebKit versions.
Implementation: emitting modulepreload correctly
Step 1 — Map the module graph
You cannot hint what you have not enumerated. For bundled apps, read the build manifest (.vite/manifest.json in Vite, or your Rollup plugin’s equivalent), which lists each chunk’s imports recursively. For unbundled dev-style deployments, load the page once and reconstruct the graph from the DevTools Network panel’s Initiator column — each module names the module that imported it, giving you the discovery chain to invert.
Step 2 — Hint the entry and its direct static imports
Emit the hints in <head>, before any render-blocking stylesheet if the module graph gates interactivity:
<!-- Scheduling rationale: the loader would discover render.js and state.js
only after entry.js arrives and parses. Hinting them here moves their
fetches from RTT 2 back to RTT 1, in parallel with the entry chunk. -->
<link rel="modulepreload" href="/assets/entry.8f21c0.js">
<link rel="modulepreload" href="/assets/render.3ab9e4.js">
<link rel="modulepreload" href="/assets/state.51d7f2.js">
<!-- The consumer: same URL, same (default) credentials mode -->
<script type="module" src="/assets/entry.8f21c0.js"></script>
Step 3 — Cover transitive dependencies explicitly
Because engines skip the spec’s optional dependency-fetch step, second- and third-level modules need their own hints. Walk the manifest transitively and emit one hint per module in the first-render graph:
<!-- Scheduling rationale: vendor.js sits two import levels below the entry.
Without its own hint it would start at RTT 3 even though the two modules
above it were hinted — every unhinted level reintroduces a serial hop. -->
<link rel="modulepreload" href="/assets/vendor.a01f9b.js">
Cap the eager set at the modules genuinely required for first render. Each hint is a High-priority fetch competing with your stylesheet and LCP image; graphs beyond roughly 15 eager modules usually indicate the chunking strategy needs attention before the hinting does.
Step 4 — Align crossorigin and integrity with the consumer
<!-- Scheduling rationale: the CDN copy is cross-origin, so credentials mode
decides cache-key identity. Both the hint and the script tag say
crossorigin (anonymous) — a mismatch on either side means the warmed
module is ignored and refetched from scratch. -->
<link rel="modulepreload"
href="https://cdn.example.com/assets/entry.8f21c0.js"
crossorigin
integrity="sha384-Qw9m0Zr9wHq...">
<script type="module"
src="https://cdn.example.com/assets/entry.8f21c0.js"
crossorigin
integrity="sha384-Qw9m0Zr9wHq..."></script>
If the consuming element uses crossorigin="use-credentials", the hint must too. The integrity values must be byte-identical; a differing hash invalidates the preloaded module record.
Step 5 — Configure the bundler
Vite emits <link rel="modulepreload"> tags for the entry chunk’s static import graph at build time, and its runtime preload helper injects hints for a dynamic import’s dependencies at import() time. Both behaviours are controlled under build.modulePreload:
// vite.config.js
// Scheduling rationale: the polyfill manually fetches hinted modules in
// engines that ignore the hint (pre-115 Firefox, pre-17 Safari), so those
// users get parallel fetches too. resolveDependencies trims speculative
// hints for chunks this route can never load, keeping High-priority
// bandwidth focused on the real critical graph.
export default {
build: {
modulePreload: {
polyfill: true,
resolveDependencies: (filename, deps) => {
return deps.filter((dep) => !dep.includes('admin-'))
}
}
}
}
Rollup without Vite does not write HTML, so pair it with an HTML plugin that reads the generated chunk graph and emits one hint per static dependency. Whatever the pipeline, verify the output: view source on the built page and confirm a modulepreload line exists for every chunk the entry statically reaches.
Step 6 — Order hints after the import map
Import maps rewrite bare specifiers to URLs, and the map must be registered before the loader resolves anything. Two rules keep the combination correct:
<!-- Scheduling rationale: the import map must be parsed before any module
fetch begins, or the engine rejects the map. And the hint must name the
mapped URL — a bare specifier in href is not a URL and the hint no-ops. -->
<script type="importmap">
{
"imports": {
"d3": "/vendor/d3.v7.min.js"
}
}
</script>
<link rel="modulepreload" href="/vendor/d3.v7.min.js">
Place the importmap script above every modulepreload link, and hint the resolved right-hand-side URL. If a deploy changes the mapping while stale HTML still hints the old URL, the preload is wasted and the import fetches the new target cold — version the map and the hints together.
Verification workflow
DevTools checks
- Open Chrome DevTools → Network, filter by JS, and enable the Priority and Initiator columns (right-click any column header).
- Every hinted module should start within the first waterfall band — before the entry script’s own request would have discovered it — with Priority
Highand Initiator showing the document orlink, not a parent module. If a supposedly hinted module’s initiator is another.jsfile, the hint for it is missing or malformed. - Duplicate-fetch detection: type the chunk’s filename into the Network filter box. Exactly one row per module is correct. Two rows with the same URL — one from the link, one from the module loader — is the signature of a
crossoriginorintegritymismatch between hint and consumer. - Check the Console for the Chromium warning that a preloaded resource was not used within a few seconds of load; for modules this flags hints pointing at chunks the page never imports (stale manifest, renamed chunk hash).
- In the Performance panel, record a load and look for Compile module tasks that complete before the entry script’s Evaluate module task begins — direct evidence the hint moved compilation off the critical path. The timing bands to read are covered in decoding the Chrome DevTools network waterfall.
Resource Timing spot-check
// Verification: every module that arrived via a hint reports initiatorType
// "link". Modules reporting initiatorType "script" were discovered the slow
// way — by a parent module's parse — and need their own modulepreload.
performance.getEntriesByType('resource')
.filter((e) => e.name.endsWith('.js'))
.map((e) => ({
file: e.name.split('/').pop(),
initiator: e.initiatorType,
startMs: Math.round(e.fetchStart),
priority: e.priority ?? 'n/a'
}))
Run it once on a cold load. The startMs values for all hinted modules should sit within a few milliseconds of each other; a stair-step in the start times reproduces the discovery serialization the hints were meant to remove.
Edge cases & gotchas
crossorigin mismatch double-fetches the module
The classic failure: <link rel="preload" as="script"> (no-CORS by default) warming a file consumed by <script type="module"> (always CORS). The request modes differ, the cache keys differ, and the module loader fetches again. The same trap exists within modulepreload itself when the hint says crossorigin but the script tag says crossorigin="use-credentials", or vice versa. Audit both attributes as a pair whenever a hinted module shows two Network rows.
modulepreload without a matching import wastes bytes and CPU
A hint pointing at a module the page never imports costs the full download at High priority plus parse and compile work — strictly worse than an unused plain preload, which at least skips compilation. Stale hint lists after a refactor are the usual cause; content-hashed filenames make this loud (the old hash 404s), while unhashed names fail silently. Regenerate hints from the manifest on every build rather than hand-maintaining them.
nomodule dual builds
During the 2018–2023 support gap, differential serving shipped a modern module build alongside a legacy <script nomodule> bundle. Engines that understood modules ignored nomodule; legacy engines ignored type="module" and modulepreload alike, so the hints were free. If you still ship dual builds, keep all modulepreload hints scoped to the module build’s chunks — hinting the legacy bundle burns bandwidth in every modern browser. Most teams can now drop nomodule entirely and simplify.
Workers have their own module map
modulepreload populates the document’s module map. A module worker (new Worker(url, { type: 'module' })) resolves its graph against a separate map, so document-level hints do not warm a worker’s imports — at best the HTTP cache entry is shared for the fetch itself. Chromium’s as="worker" sets the right destination for the worker’s entry file, but the worker’s internal dependencies still discover serially. Keep worker graphs shallow, or bundle the worker into a single file.
Dynamic injection timing
Hints injected by JavaScript work, but only add value if they reach the scheduler before the module loader would have discovered the same URL. Injecting modulepreload in the same task that calls import() gains nothing for the imported file itself (the loader is already fetching it) — the win is hinting that chunk’s dependencies, which the loader has not seen yet. The broader injection patterns and their timing traps are covered in dynamic hint injection via JavaScript.
FAQ
Can I use rel="preload" as="script" for an ES module instead of modulepreload?
Only as a compatibility fallback, and only with crossorigin set. A classic as="script" preload defaults to a no-CORS fetch while module scripts always fetch in CORS mode, so an unadorned preload warms a cache entry the module loader cannot reuse — producing a double fetch. Even when the CORS modes are aligned, a plain preload stores raw bytes without parsing or compiling, so the module map stays cold and the parse cost lands back on the critical path. It was a defensible pattern while Safari and Firefox ignored modulepreload; today the real hint is supported everywhere that matters.
Does modulepreload fetch a module’s own imports automatically?
No shipping engine does this. The HTML spec permits a user agent to also fetch the preloaded module’s declared dependencies, but Chromium, Gecko, and WebKit all fetch exactly the one URL in the hint. Treat dependency preloading as your job: enumerate the transitive graph from the build manifest and emit one hint per module, or the unhinted levels quietly reintroduce serial round trips.
How many modulepreload hints are safe on one page?
Keep the eagerly hinted set to the modules needed for first render — typically 8 to 15 files after sensible chunking. Every hint is a High-priority fetch that competes with stylesheets and the LCP image for early bandwidth, and every arrived module consumes compile CPU and memory whether or not it runs soon. Warm below-the-fold and route-specific modules on interaction signals instead of eagerly in <head>.
Do modulepreload hints still help on repeat visits?
Yes, but less dramatically. On a warm HTTP cache the fetch cost collapses, and engines like V8 reuse a bytecode cache for modules compiled on previous visits. The hint still removes the serialized discovery walk — each cache revalidation or disk read otherwise happens level by level down the graph — and it costs nothing when everything is cached, so keep the hints in place rather than special-casing repeat views.
Related
- Fixing Dynamic Import Request Waterfalls — deep-dive into the stair-step pattern on route changes and how to flatten it
- Preload vs Prefetch vs modulepreload: a Decision Matrix — which of the three hints fits each resource
- Resource Hint Implementation & Preloading Strategies — up to the topic-area root