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.
The cost being removed is arithmetic, not intuition. Take a graph three levels deep on a 120 ms round-trip mobile connection: the entry chunk cannot be requested until the HTML names it, its two direct imports cannot be requested until the entry’s bytes have arrived and been parsed, and the shared vendor chunk beneath them waits on that. None of it is bandwidth-bound — the files are small — yet the last module lands roughly 360 ms after the first request on a link with capacity to spare. modulepreload collapses those serial hops into one dispatch, and the rest of this guide is about doing that without double-fetching, over-hinting, or breaking on the engines that reached the feature five years late.
How ES module loading differs from classic scripts
A classic <script src="app.js"></script> is a single opaque fetch: the browser downloads one file, executes it, done. A <script type="module" src="entry.js"></script> 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.
Because module entries share a scheduling class with deferred classic scripts, the ordering rules from async, defer and script execution order carry over unchanged: several deferred module entries evaluate in document order regardless of which finished downloading first, while an async module entry evaluates the moment its own graph is ready and can therefore run ahead of an entry declared above it.
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. Credentials mode is part of the HTTP cache key in every current engine, so this is not a soft mismatch that costs a revalidation — the second request has nothing to hit and goes to the network in full.
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.
Two details make this worse than the depth number alone suggests. First, a “round trip” here is not only latency: each level pays time to first byte plus at least one congestion window of transfer, and a cold connection’s window is small, so a 96 KB vendor chunk discovered at level two may need two or three windows of its own before the level below it is even visible. Second, the preload scanner is structurally blind to the problem. It tokenises raw HTML looking for src and href attributes; import './chart.js' lives inside a JavaScript body the scanner never parses, so the one mechanism that normally rescues late-discovered subresources cannot fire at all.
Evaluation order is post-order, not arrival order
Warming the graph changes when bytes arrive, never when code runs. Once every module is fetched and linked, evaluation walks the graph depth-first in post-order: the deepest dependency’s top-level body runs first, then its importer, and so on up to the entry. A module whose hint made it arrive first still waits for its own dependencies to evaluate. That property is what makes modulepreload safe to add anywhere — it cannot reorder side effects or change which module initialises first — and it is also why the hint does nothing for a graph whose real bottleneck is a slow top-level await rather than a slow fetch.
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.
The module map: the structure a hint actually populates
Almost every surprising behaviour in this area comes out of one data structure rather than out of the hint itself. Each module map settings object — a document, a dedicated worker, a shared worker, a worklet — owns its own module map: a dictionary whose key is the pair (resolved absolute URL, module type) and whose value is one of three things. A placeholder meaning “a fetch for this is already in flight”, a fully parsed and linked module record, or null, meaning “this one failed”.
Four consequences follow directly, and each one shows up in production as a different bug:
- Deduplication is free; parallelism is not. If
render.jsandstate.jsboth importvendor.js, the second request finds the in-flight placeholder and attaches to it. One fetch, one parse, one evaluation. What the map cannot do is anticipate — it only deduplicates requests the loader has already been told about, which is exactly the gap a hint fills. - The key includes the module type.
import cfg from './cfg.json' with { type: 'json' }files its entry under a JSON key. Amodulepreloadfor the same URL warms the JavaScript key, so the typed import misses it entirely and refetches. - Failure is recorded, not retried. When a fetch 404s, fails CORS, or the body fails to parse, the map stores
nullfor that key. Every later import of the same URL in the same document is rejected straight from the map without a second network attempt. There is no backoff and no retry; only a reload clears it. - Evaluation is decoupled from fetching. The map holds linked records; running them is a separate, deterministic post-order walk. Hints move the first three phases earlier and leave the fourth exactly where it was.
That last state is the one people trip over. A hint is not a speculative side channel — it drives the same state machine the real import would, which is precisely why it works, and precisely why a hint that points at a chunk hash from the previous deploy is worse than no hint at all. It does not merely waste a request; it writes a failure into the map that the subsequent genuine import inherits. Content-hashed filenames make this loud, because the missing file 404s and the console fills with module errors instead of silently degrading.
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.
Step two deserves more credit than it usually gets. V8 compiles a hinted module off the main thread while the bytes are still streaming in, so for a 96 KB vendor chunk the 20–40 ms of parse-and-compile work overlaps the download rather than following it. On a repeat visit the same step consults the code cache: V8 persists compiled bytecode for scripts it has seen more than once and can rehydrate a hinted module from it before the main thread ever asks. Neither benefit is available to rel="preload", which stores bytes and nothing else.
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 module script element |
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.
fetchpriority is the underused one. Because every modulepreload defaults to High, a page that hints twelve chunks has twelve requests contending with the stylesheet and the LCP image inside the same band. Marking the four chunks needed for first paint fetchpriority="high" and the rest fetchpriority="low" keeps the full graph warm while letting the scheduler drain the critical four first — a much better outcome than deleting the eight speculative hints outright, since a Low-priority module still arrives well before a serially discovered one.
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 (type="importmap" scripts) |
89 / 89 | 108 | 16.4 |
Module scripts (type="module") themselves |
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.
Reading the chunk graph to decide what to hint
Everything above says “hint the graph”. The practical question is which files that means, and the answer lives in the build manifest rather than in your source tree — after chunking, the shape of the emitted graph rarely matches the shape of your imports. A typical Vite manifest for a single route looks like this:
{
"src/main.js": {
"file": "assets/entry.8f21c0.js",
"isEntry": true,
"imports": ["_render.3ab9e4.js", "_state.51d7f2.js"],
"dynamicImports": ["src/routes/admin.js"]
},
"_render.3ab9e4.js": {
"file": "assets/render.3ab9e4.js",
"imports": ["_vendor.a01f9b.js"]
},
"_state.51d7f2.js": {
"file": "assets/state.51d7f2.js",
"imports": ["_vendor.a01f9b.js"]
},
"_vendor.a01f9b.js": { "file": "assets/vendor.a01f9b.js" },
"src/routes/admin.js": { "file": "assets/admin.7c2d1a.js", "isDynamicEntry": true }
}
Four static chunks, one dynamic chunk, and a maximum static depth of three. The imports arrays are what you walk; dynamicImports is what you deliberately leave out of the eager hint set.
Read the shared vendor chunk carefully, because it is where the intuition breaks. It is the biggest file in the graph at 96 KB, it is imported by two different parents, and the module map guarantees it is fetched only once — but when that single fetch starts is decided by whichever parent parses first, which is round trip two at the earliest. Hint it explicitly and the 96 KB body starts transferring at time zero, giving it the whole of the first two round trips to work through slow start instead of beginning cold at RTT 2. That single hint is usually worth more than the two level-one hints combined.
The dashed edge is the other half of the decision. admin.7c2d1a.js is only reachable through import('./routes/admin.js'), so eagerly hinting it would spend 61 KB of High-priority bandwidth on a route most visitors never open. Vite’s runtime helper already injects a hint for it — and for its own dependencies — at the moment the import() call runs, which is the correct time. The full walk from manifest entries to a hint list, including the shared-chunk and dynamic-entry cases that make a naive walk emit the wrong set, is worked through in mapping Vite chunk graphs to modulepreload.
Generating the list is a dozen lines once you have the manifest:
// build/emit-hints.mjs
// Walk the manifest from the entry outward and print one hint per static chunk.
// Dynamic imports are deliberately excluded: Vite's runtime helper hints them at
// import() time, and hinting them here spends High-priority bandwidth on a route
// the visitor may never open.
import { readFileSync } from 'node:fs'
const manifest = JSON.parse(readFileSync('dist/.vite/manifest.json', 'utf8'))
const entryKey = Object.keys(manifest).find((k) => manifest[k].isEntry)
function staticGraph(key, seen = new Set()) {
if (seen.has(key)) return seen // shared chunk — visited once, like the module map
seen.add(key)
for (const dep of manifest[key].imports ?? []) staticGraph(dep, seen)
return seen
}
const files = [...staticGraph(entryKey)].map((k) => manifest[k].file)
console.log(files.map((f) => `<link rel="modulepreload" href="/${f}">`).join('\n'))
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.
One caveat that bites server-rendered stacks specifically: Vite only writes those tags into HTML files it processes itself. If your HTML comes from Rails, Django, Laravel or a Go template, the build produces the manifest but nothing injects the hints, and the page ships with zero of them while the developer assumes the bundler handled it. That is the case the generator script above exists for.
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). The full set of causes behind that message, including the ones that are false alarms, is catalogued in debugging “preloaded but not used” console warnings.
- 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.
Keeping the hint set honest in CI
Manual verification decays the moment someone adds a dependency. Make the check mechanical: run the generator from the chunk-graph section against the fresh build, extract the modulepreload hrefs from the HTML your server actually returns, and fail the pipeline when the two sets differ.
// scripts/check-hints.mjs — run after build, before deploy
const expected = new Set(hintedFiles) // from the manifest walk
const actual = new Set(
[...renderedHtml.matchAll(/rel="modulepreload"\s+href="([^"]+)"/g)].map((m) => m[1])
)
const missing = [...expected].filter((f) => !actual.has(f))
const stale = [...actual].filter((f) => !expected.has(f))
if (missing.length || stale.length) {
throw new Error(`hint drift — missing: ${missing}; stale: ${stale}`)
}
The failure this catches is invisible in every other test: a new shared chunk appears in the graph, the template still lists yesterday’s four hints, and one level of the graph quietly reverts to serial discovery. Nothing errors, nothing 404s, and the regression shows up weeks later as an unexplained 200 ms of extra interaction latency on mobile.
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 a type="module" script element (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.
JSON and CSS modules cannot be warmed by the hint
Import attributes create module map entries under a non-JavaScript type: import cfg from './cfg.json' with { type: 'json' } keys its entry as a JSON module. modulepreload has no attribute that expresses that type, so the hint warms the JavaScript key and the real import misses it — you pay for the download twice and get a MIME-type error on the first attempt in strict engines. The workable substitute is <link rel="preload" href="/cfg.json" as="fetch" crossorigin>, which does not populate the module map but does put the bytes in the HTTP cache, so the eventual typed fetch is a cache hit rather than a round trip.
nomodule dual builds
During the 2018–2023 support gap, differential serving shipped a modern module build alongside a legacy bundle marked nomodule. 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.
Inline module entries cannot be hinted, but their imports can
<script type="module">import './boot.js'</script> has no URL of its own, so there is nothing for a hint to name. The graph beneath it is still hintable, and hinting it matters more than usual here: the inline body is not visible to the preload scanner either, so boot.js is discovered only when the HTML parser reaches the inline script and the module pipeline compiles it. A single <link rel="modulepreload" href="/boot.js"> in <head> moves that discovery to the top of the document.
Early Hints can start the graph before the HTML exists
A 103 informational response may carry Link: </assets/entry.8f21c0.js>; rel=modulepreload, dispatching the fetch during server think time. It is only worth wiring up when the origin is slow to produce HTML — with a low time to first byte, hints in <head> start at practically the same instant. The rollout mechanics and the cache-safety rules are covered under 103 Early Hints implementation.
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.
Can I modulepreload a JSON or CSS module?
Not usefully. The module map key is the pair of resolved URL and module type, and the hint has no attribute for expressing a non-JavaScript type, so the warmed record is filed under the JavaScript key while import cfg from './cfg.json' with { type: 'json' } looks up the JSON key and misses. Warm the bytes with rel="preload" as="fetch" crossorigin instead: you lose the parse-ahead benefit but still remove the round trip, which is the larger of the two costs on any real network.
Should modulepreload hints come before or after the stylesheet link?
Put the render-blocking stylesheet first unless interactivity, rather than paint, is your bottleneck. Both land in Chromium’s High band and are dispatched in roughly document order, so hints placed above the stylesheet take early bandwidth away from first paint. On a page whose LCP element is text or a CSS background, the stylesheet wins that trade every time. On an app shell that paints nothing at all until the module graph has evaluated, the hints should go first — and if the answer is genuinely “both are critical”, that is the signal to split the eager hint set with fetchpriority rather than to argue about ordering.
Can 103 Early Hints carry modulepreload?
Yes — a Link header with rel=modulepreload is honoured in a 103 informational response exactly as a preload header is, and the module fetches begin while the origin is still assembling the HTML. The gain is bounded by your server think time: if time to first byte is already 40 ms, hints in <head> arrive at effectively the same moment and the extra deployment complexity buys nothing. It pays on personalised or database-heavy pages where the first byte lands hundreds of milliseconds after the request.
Related
- Mapping Vite Chunk Graphs to modulepreload — turning
.vite/manifest.jsoninto the exact hint list a server-rendered template should emit - 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