Mapping Vite Chunk Graphs to modulepreload
Symptom: the built dist/.vite/manifest.json lists eleven JavaScript chunks, but the server-rendered <head> carries a single <script type="module"> and zero modulepreload links — so a cold load fetches the entry graph in three discovery waves and interactivity lands ~190 ms later than the bytes require.
Root cause: two hint emitters, and a template neither of them reaches
Vite emits modulepreload hints from two independent places, and both are tied to artifacts a server-rendered page does not have.
The first is the HTML plugin at build time. When an .html file is an input to the build, Vite rewrites its script tags to the hashed chunk names and injects one <link rel="modulepreload" crossorigin> per member of the entry chunk’s flattened static import graph — not just the first level. That flattening is the whole point: it is what removes the level-by-level discovery walk described in the modulepreload and ES module loading guide. In a backend-integrated build there is no HTML input. Vite’s only deliverable is the manifest, the framework renders the head from its own template, and the injection step simply never runs. Nothing warns you: the page works, it is just three round trips slower than the build intended.
The second emitter is the runtime helper. Vite’s import analysis rewrites every import('./x.js') into __vitePreload(() => import('./x.js'), deps), where deps is a literal array baked in at renderChunk time containing the flattened static dependencies of the target chunk. At call time the helper appends a modulepreload link for each entry in that array, then performs the import — all inside one task, so the route chunk and its dependencies leave in the same wave. This emitter does survive into a server-rendered app, because it lives inside the JavaScript. It covers dynamic entries and nothing else.
It matters that the first emitter writes markup and the second writes DOM. A <link rel="modulepreload"> present in the served HTML is picked up by the preload scanner, which runs ahead of the parser over the raw byte stream and dispatches the fetch before the main parser has reached the tag — the same mechanism that makes a stylesheet in <head> so fast. A link element created by __vitePreload and appended with document.head.appendChild() cannot benefit from that: it enters the scheduler only when the helper runs, which is after the entry chunk has downloaded, parsed and begun executing. The two emitters therefore sit on opposite sides of the first script execution, and no amount of runtime cleverness moves the second one earlier.
That leaves three gaps, and they behave differently. Chunks reachable from the entry through static imports get no hint at all — the loader discovers them by parsing, one level per round trip. Chunks reachable only through a nested import() inside an already-lazy chunk are covered by neither emitter until their parent has downloaded, parsed and executed; that is the stair-step examined in fixing dynamic import request waterfalls. And CSS attached to a chunk (manifest[key].css) is injected as a render-blocking stylesheet by the same helper, so a missing template hint costs paint time as well as script time.
Chunking strategy decides how expensive the gap is. Rollup’s default behaviour already hoists code shared by two or more entries into its own chunk, and a hand-written manualChunks that splits vendors by package pushes the graph both wider and deeper: a react chunk importing a scheduler chunk is two levels, and a design-system chunk importing an icon chunk is two more. Every added level is another round trip on a template that hints nothing, which is why the same build can look fine in vite preview — served from Vite’s own generated HTML, fully hinted — and three waves slower in production behind a server-rendered head.
Reading the manifest as a graph
build.manifest: true writes dist/.vite/manifest.json. Its keys are source paths for anything with a source file, and _-prefixed synthetic names for shared chunks Rollup created; crucially, the strings inside imports and dynamicImports are manifest keys, not file names, so a naive href built from them 404s. The file field is the emitted path relative to the build output root.
{
"src/main.ts": {
"file": "assets/main-B7x2f9.js",
"src": "src/main.ts",
"isEntry": true,
"css": ["assets/main-9c1d4e.css"],
"imports": ["_vendor-react-Ck91aQ.js", "_ui-kit-D0a4Ub.js"],
"dynamicImports": ["src/routes/reports.ts"]
},
"_vendor-react-Ck91aQ.js": {
"file": "assets/vendor-react-Ck91aQ.js",
"imports": ["_scheduler-Ba17Xt.js"]
},
"src/routes/reports.ts": {
"file": "assets/reports-Fp82Kd.js",
"isDynamicEntry": true,
"dynamicImports": ["src/charts/chart-core.ts"]
}
}
Reading imports recursively from src/main.ts yields the exact set the HTML plugin would have hinted: vendor-react, ui-kit, scheduler. Reading dynamicImports yields the set you should deliberately not hint from the head.
Six fields carry all the scheduling information you need, and each one maps to a decision:
| Manifest field | Type | What it decides |
|---|---|---|
file |
string | The emitted path — the only value that may ever become an href. Content-hashed, so it changes on every meaningful edit |
isEntry |
boolean | The chunk your template loads with <script type="module">. Its flattened graph is the eager hint set |
imports |
array of keys | Static dependencies. Follow recursively: this is the walk the browser would otherwise pay one round trip per level for |
dynamicImports |
array of keys | Chunks reached through import(). Do not follow when building the eager set — the runtime helper owns them |
isDynamicEntry |
boolean | Marks a chunk that is the target of an import(). Present on the record itself, so you can classify without inverting the graph |
css |
array of paths | Stylesheets Rollup attached to this chunk. Render-blocking for the entry, injected by the helper for a dynamic entry |
Two details bite people. First, a chunk can appear in both an imports array and a dynamicImports array — a shared component statically imported by the entry and dynamically imported by a route. Deduplicate by manifest key, and let the static path win: it is needed eagerly regardless. Second, file paths are relative to the build output root, not to the URL space, so a deployment served from a sub-path or a CDN origin must prefix them with the same base the build was configured with. A hint whose href disagrees with the script’s URL by even one path segment is a second cache key and a second download.
Minimal reproduction
Four source files and a server template that renders the entry by hand — the shape every backend integration lands on.
// src/main.ts — the only URL the template knows about.
// vendor-react and ui-kit are invisible to the browser until these bytes
// arrive and parse, which is the first serialized round trip.
import { mount } from './ui-kit'
import { createRoot } from 'react-dom/client'
document.querySelector('#app')?.addEventListener('click', () => {
// Dynamic: rewritten to __vitePreload(() => import(...), [...deps])
import('./routes/reports')
})
<!-- server template — renders from manifest.file and nothing else.
The module script is discovered by the preload scanner, but its three
static dependencies are not: they live inside bytes that have not
arrived, so each graph level costs a full round trip. -->
<link rel="stylesheet" href="/assets/main-9c1d4e.css">
<script type="module" crossorigin src="/assets/main-B7x2f9.js"></script>
Load it cold on a throttled 4G profile (9 Mbps down, 85 ms RTT) and the Network panel shows three waves: main alone, then vendor-react and ui-kit together, then scheduler. Every request after the first has a .js file as its Initiator, which is the unambiguous fingerprint of parse-driven discovery — a hinted chunk always reports link.
Two properties of the trace confirm the diagnosis rather than a bandwidth problem. The connection is idle between waves: hover the gap in the waterfall and the entry’s own transfer has finished, with nothing queued behind it. And the gaps scale with latency, not with size — raise the throttle to 30 Mbps and the three waves stay 85 ms apart, because what is being paid for is a round trip, not bytes. That is the signature to look for before reaching for any of the fixes below; if the gaps shrink when bandwidth rises, the problem is chunk size and no hint will help.
Deterministic fix protocol
- [ ] 1. Emit the manifest and treat it as the source of truth. Set
build.manifest: trueinvite.config.jsand confirmdist/.vite/manifest.jsonexists aftervite build. Hand-maintained hint lists go stale on the first content-hash change; every step below reads the manifest instead. - [ ] 2. Classify each chunk before you hint anything. Three groups, three treatments — reachable from an entry through
imports, a dynamic entry the current page can reach, and a dynamic entry for an unlikely route. The middle group is already handled; the right-hand group must stay out of<head>.
The right-hand branch is the one teams get wrong in the optimistic direction. It is tempting to hint everything the manifest contains — the list is right there, the build already produced it, and every hint “makes something faster”. But the browser’s High-priority band is finite: on the reproduction below, hinting all eleven chunks pushed the hero image’s fetch start out by 240 ms and cost more LCP than the flattened graph gained in script time. Treat the eager set as a budget of eight to fifteen files, spend it on the static graph, and let intent-driven warm-up handle the rest.
- [ ] 3. Flatten the static graph at build time, never per request. Walk
importsrecursively from each entry key and write the result to a small artifact the server reads once at boot:
// scripts/build-hints.mjs — run immediately after `vite build`.
import { readFile, writeFile } from 'node:fs/promises'
const manifest = JSON.parse(await readFile('dist/.vite/manifest.json', 'utf8'))
// Scheduling rationale: this reproduces exactly what Vite's HTML plugin would
// have injected — the entry's transitive STATIC graph. dynamicImports are
// deliberately not followed: __vitePreload already hints those at import()
// time, and promoting them to head would put a route the visitor may never
// open into the same High-priority band as the stylesheet and the LCP image.
function flattenStatic(key, seen = new Set()) {
if (seen.has(key) || !manifest[key]) return seen
seen.add(key)
for (const dep of manifest[key].imports ?? []) flattenStatic(dep, seen)
return seen
}
const entry = 'src/main.ts'
const keys = [...flattenStatic(entry)].filter((k) => k !== entry)
await writeFile('dist/hints.json', JSON.stringify({
entry: manifest[entry].file,
css: manifest[entry].css ?? [],
// `imports` holds manifest KEYS, not paths — resolving through .file here is
// what stops the generated href from 404-ing after a content-hash change.
modulepreload: keys.map((k) => manifest[k].file)
}))
- [ ] 4. Render the hints above the module script. Order matters only for the scanner, not the loader — but getting all hints into the first HTML packet is free, so put them first:
<!-- Scheduling rationale: these three links are what the entry's parse would
have discovered at RTT 2 and RTT 3. Emitted here they are dispatched by
the preload scanner in the same wave as the entry itself, so the module
map is warm before the loader ever asks for them. -->
<link rel="modulepreload" crossorigin href="/assets/vendor-react-Ck91aQ.js">
<link rel="modulepreload" crossorigin href="/assets/ui-kit-D0a4Ub.js">
<link rel="modulepreload" crossorigin href="/assets/scheduler-Ba17Xt.js">
<link rel="stylesheet" href="/assets/main-9c1d4e.css">
<script type="module" crossorigin src="/assets/main-B7x2f9.js"></script>
- [ ] 5. Keep
crossoriginidentical on hint and consumer. Vite writescrossoriginon the tags it generates; if your template keeps it on the script but drops it on the generated hint, the two requests can key differently and the warmed entry is discarded. Audit them as a pair — two Network rows for one chunk is the tell. - [ ] 6. Do not re-hint dynamic entries from the head. Anything with
isDynamicEntry: trueis the runtime helper’s territory. Duplicating those URLs converts an on-demand fetch into an eager High-priority one that competes with first paint, for zero benefit when the route is not visited. - [ ] 7. Trim the runtime helper’s dependency list, with a
hostTypeguard.resolveDependenciesis called by both emitters; filtering without checking which one is asking silently strips your entry hints too:
// vite.config.js
export default {
build: {
manifest: true,
modulePreload: {
// Scheduling rationale: the polyfill fetches hinted URLs manually in
// engines that ignore the hint, so those users still get one wave.
polyfill: true,
resolveDependencies(filename, deps, { hostType }) {
// hostType 'html' is the build-time tag set for the entry — never trim
// it, or the serial discovery walk returns on first load. Only the 'js'
// call, which bakes __vitePreload's array into a chunk, is filtered.
if (hostType === 'html') return deps
return deps.filter((dep) => !dep.includes('admin-'))
}
}
}
}
- [ ] 8. Verify one wave and one row per URL. Reload cold with the cache disabled and confirm every static chunk reports Initiator
link, PriorityHigh, and a start time within a few milliseconds of the entry’s. Reading those columns is covered in decoding the Chrome DevTools network waterfall. Then confirm from Resource Timing that nothing is being fetched twice:
// Verification: a hinted chunk reports initiatorType "link". Any .js still
// reporting "script" was discovered by a parent module's parse and is missing
// from hints.json; a duplicated name means the hint and the script tag
// disagree on crossorigin and the warmed entry was thrown away.
const rows = performance.getEntriesByType('resource')
.filter((e) => e.name.endsWith('.js'))
.map((e) => [e.name.split('/').pop(), e.initiatorType, Math.round(e.fetchStart)])
console.table(rows)
- [ ] 9. Generate one hint set per entry, not one for the app. A multi-page build has several
isEntryrecords, and their flattened graphs overlap only partially. Keyhints.jsonby entry and have the template look up the entry it is actually rendering; a shared union list quietly hints every page’s graph on every page, which is the over-hinting failure from step 2 arriving through the back door. Regenerate the file in the same CI step that runsvite build, and fail the build if anyfilereferenced byhints.jsonis missing fromdist/— that check catches a stale artifact before it reaches production, where a wrong hash is a silent 404 in the hint set and a full-price cold fetch for the real chunk.
Before/after metrics
Same build, same bytes, same 4G profile (9 Mbps down, 85 ms RTT), cold cache, five-run median.
| Metric | Before (template hints nothing) | After (manifest-derived hints) | Change |
|---|---|---|---|
modulepreload links in <head> |
0 | 3 | +3 |
| Discovery waves for the entry graph | 3 | 1 | −2 |
scheduler.js fetch start |
196 ms | 4 ms | −192 ms |
| Entry graph evaluated | 312 ms | 121 ms | −61 % |
| Network rows per chunk | 1 | 1 | unchanged |
| JS bytes transferred | 184 KB | 184 KB | unchanged |
| LCP | 1 980 ms | 1 910 ms | −70 ms |
Bytes are identical because nothing was added or removed — the hints only tell the scheduler earlier. The one row worth watching over time is LCP: hint all eleven chunks instead of three and the same measurement went to 2 240 ms in this build, because eight extra High-priority fetches took bandwidth from the hero image. Correct mapping is as much about the chunks you leave out as the ones you list.
Two numbers are worth adding to a dashboard rather than checking once. The count of .js Resource Timing entries whose initiatorType is script should be zero on a first load and stays zero only while the generator runs on every build; the day someone adds a static import to a chunk that is not in the flattened walk, it goes to one. And the spread between the earliest and latest fetchStart among entry-graph chunks is a direct read of how many waves survive — a few milliseconds means one wave, a value near a whole RTT means the mapping has regressed. Both are cheap to sample from the field and neither depends on reproducing a throttled profile.
FAQ
Vite already writes modulepreload links into index.html — why is my page missing them?
Because that injection belongs to the HTML plugin, and it only runs for HTML files that are inputs to the Vite build. In a backend-integrated or server-rendered setup the framework owns the head and Vite never sees it, so the only artifact you get is dist/.vite/manifest.json. The same gap opens when build.modulePreload is set to false, and in an SSR build, which produces no browser HTML at all. Diffing the generated index.html from a plain vite build against your rendered head is the fastest way to see exactly which tags went missing: run the build once with an HTML entry, read the tag list it produces, and treat that list as the specification your template has to reproduce from the manifest.
Should I hint dynamic route chunks from the template too?
Usually not. A dynamic entry is already covered by __vitePreload, which injects hints for that chunk’s flattened static dependencies in the same task as the import() call, so the chunk and its dependencies leave in one wave. Adding those URLs to <head> promotes them to a High-priority fetch competing with the stylesheet and the LCP image during the initial load, for a route the visitor may never open. Warm them on navigation intent instead, and use the preload versus prefetch versus modulepreload decision matrix when the route is probable but not imminent.
Does resolveDependencies also change the tags written into the HTML?
Yes, and that is the trap. The hook runs for both emitters, and the third argument says which: hostType is 'html' when Vite is deciding the build-time link tags and 'js' when the runtime helper’s dependency array is being baked into a chunk. A filter written without that guard strips hints out of the entry HTML as well, reintroducing the serial discovery walk on first load while looking like a purely route-level optimisation. Guard on hostType first, then filter. The failure is easy to miss in review because the config reads as a route concern and the symptom appears on the home page, several steps away from the code that caused it; the cheapest guard is a build-time assertion that the generated index.html still contains the expected number of modulepreload tags.
Related
- modulepreload & ES Module Loading — parent topic: what the hint fetches, compiles and registers
- Fixing Dynamic Import Request Waterfalls — flattening the stair-step that nested
import()chunks create at route change - Resource Hint Implementation & Preloading Strategies — up to the section root