Fixing Dynamic Import Request Waterfalls
A route-level import() resolves only after its chunk has downloaded and parsed, so every nested static import underneath it adds a full network round trip — route transitions stall in a stair-step of serial chunk fetches that no amount of bandwidth can flatten.
Root Cause: Discovery Is Gated on the Parent Chunk’s Bytes
When a router calls import('./pages/reports.js'), the browser knows exactly one URL. The fact that reports.js begins with import './chart.js' and import './grid.js' is encoded in bytes that have not arrived yet. The module loader must complete the fetch, hand the source to the parser, and walk its import declarations before it can issue the next tier of requests. Unlike initial-load HTML — where the preload scanner races ahead of the parser — there is no speculative scanner for JavaScript module source; discovery is strictly post-parse.
Code splitting makes the graph deeper than most teams realize. Bundlers pull dependencies shared between routes into their own chunks so they cache independently, which means the route chunk imports them rather than containing them. A lazy component inside the lazy route adds another tier. Three levels is typical in a mature SPA: route chunk → feature chunks → shared vendor chunk. At 80 ms RTT plus server time, that is 300–500 ms of pure sequencing appended to every cold route change — visible as a diagonal staircase in the network waterfall, each request’s start aligned with its parent’s end.
The fix has two prongs. Structurally, hoist shared dependencies so the graph is shallow. Behaviourally, tell the scheduler about the whole transitive graph before the click, by emitting <link rel="modulepreload"> for every chunk the route needs — the mechanics of what that hint fetches, compiles, and registers are covered in the modulepreload & ES module loading guide. Because navigation intent (hover, focus, a link scrolling into view) precedes the click by 150–300 ms, hints injected on intent hide at least one full round trip inside time the user was spending anyway.
Minimal Reproduction
Three small files reproduce the staircase. The router lazily imports the route; the route statically imports two features; one feature statically imports a vendor library:
// router.js — the browser knows only this URL at click time.
// Everything reports.js imports is invisible until its bytes are parsed.
const routes = [
{ path: '/reports', load: () => import('./pages/reports.js') }
]
// pages/reports.js — tier 2: discovered one RTT after the click
import { renderChart } from '../features/chart.js'
import { renderGrid } from '../features/grid.js'
export function mount(el) {
renderChart(el)
renderGrid(el)
}
// features/chart.js — tier 3: discovered two RTTs after the click,
// because grid.js and chart.js had to arrive and parse first
import * as d3 from '../vendor/vendor-d3.js'
export function renderChart(el) {
d3.select(el).append('svg') // rendering blocked until tier 3 lands
}
Load it, click the link, and read the Network panel: three request waves, each starting where the previous one ends, with the loader idle between waves.
Deterministic Fix Protocol
- [ ] 1. Record the chain. DevTools → Network → filter JS, disable cache, click the route link. Note each chunk’s Initiator: a chunk initiated by another chunk (not by
linkor the document) is a serialized tier. Count the tiers — that is your RTT multiplier. - [ ] 2. Enumerate the transitive graph per route. Read the build manifest (Vite:
.vite/manifest.json, keyed by entry with recursiveimportsarrays) and flatten each route’s chunk list, hashes included. Regenerate this list in CI on every build so hints never go stale. - [ ] 3. Warm the graph on navigation intent. Inject
modulepreloadfor the entire flattened list onpointerenter/focusin, so all tiers fetch in parallel during the hover gap:
// warm-routes.js
// Scheduling rationale: pointerenter precedes click by 150-300 ms. Injecting
// the whole transitive graph now lets tiers 1-3 download in parallel inside
// that gap, instead of serially after the click.
const routeChunks = {
'/reports': [
'/assets/reports.9f3a.js',
'/assets/chart.77b2.js',
'/assets/grid.4c11.js',
'/assets/vendor-d3.a01f.js'
]
}
function warmRoute(path) {
for (const href of routeChunks[path] ?? []) {
// Idempotency guard: a second hover must not emit duplicate hints
if (document.head.querySelector(`link[href="${href}"]`)) continue
const link = document.createElement('link')
link.rel = 'modulepreload'
link.href = href
document.head.append(link)
}
}
document.querySelectorAll('a[data-route]').forEach((a) => {
a.addEventListener('pointerenter', () => warmRoute(a.dataset.route), { once: true })
})
- [ ] 4. Add a viewport trigger for primary navigation. For links likely to be tapped on touch devices (no hover), warm the route when the link enters the viewport via
IntersectionObserver, gated onnavigator.connection.effectiveTypeso2gusers are spared. The injection timing rules are the same as for any dynamically injected hint. - [ ] 5. Hoist shared dependencies to cut graph depth. Configure manual chunking so vendor libraries sit one level below the route, not two — e.g. Rollup/Vite
manualChunksgroupingd3into a shared chunk imported directly by the route. Target depth ≤ 2 before hints; hints then remove what remains. - [ ] 6. Keep the bundler’s own dependency preload enabled. Vite’s runtime helper injects hints for a chunk’s static deps at
import()time — that alone flattens tier 3 to tier 2 even without hover warm-up. Confirmbuild.modulePreloadhas not been disabled. - [ ] 7. Verify parallelism. Re-record the route change. All chunks should now share one start time (initiator
link), PriorityHigh, and exactly one request per URL — two rows for the same chunk means acrossoriginmismatch between injected hint and loader. - [ ] 8. Measure the user-facing delta. Wrap the router transition in
performance.measure()from click to route render and compare cold-load medians before and after.
Before/After Metrics
Measured on a simulated 4G profile (9 Mbps down, 85 ms RTT) for the reproduction above, cold cache, hover preceding click by 180 ms.
| Metric | Before (serial) | After (hover warm-up) | Change |
|---|---|---|---|
| Serialized request tiers | 3 | 1 | −2 tiers |
| Click → route render | 640 ms | 190 ms | −70% |
| Deepest chunk fetch start (after click) | 205 ms | −150 ms (pre-click) | starts before click |
| Loader idle time between fetches | 170 ms | 0 ms | eliminated |
| Total bytes transferred | 214 KB | 214 KB | unchanged |
| INP for the navigation interaction | 610 ms | 180 ms | −430 ms |
Bytes are identical — the fix reorders work, it does not add or remove any. That is the signature of a scheduling repair: same payload, fewer sequential dependencies.
FAQ
Why doesn’t the browser parallelize dynamic import dependencies on its own?
Because the dependency list is data the browser does not have yet. The import statements inside a lazily loaded chunk exist only in that chunk’s source, so the loader must download and parse the parent before it can request the children — there is no speculative scanner for module source the way there is for HTML. Only an out-of-band channel, such as modulepreload hints generated from the build manifest, can tell the scheduler about the children earlier.
Should I just merge the route into one big chunk instead of hinting?
Merging trades the waterfall for worse caching and a heavier parse: any one-line change invalidates and re-downloads the whole chunk, and libraries shared across routes get duplicated into each route that inlines them. The better shape is depth-two chunking — route chunk plus hoisted shared chunks — which keeps cache granularity while leaving only one tier for hints to flatten.
Is prefetch a substitute for modulepreload on route chunks?
They solve different halves of the problem. prefetch fills the HTTP cache at idle priority long before the user commits — ideal minutes ahead of a probable navigation — but it neither compiles modules nor fixes discovery ordering at click time. modulepreload on hover does both. Robust setups use each in its slot; the three-way decision matrix formalizes the choice.
Related
- modulepreload & ES Module Loading — parent guide: what the hint fetches, compiles, and registers
- Preload vs Prefetch vs modulepreload: a Decision Matrix — choosing the right hint per resource
- Resource Hint Implementation & Preloading Strategies — up to the topic-area root