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.

Stair-step import() waterfall vs flattened parallel fetch Two waterfalls on a shared time axis with a click marker at time zero. Before: reports.js downloads from 0 to 90 milliseconds, chart.js and grid.js from 90 to 180, vendor-d3.js from 180 to 270, and the route renders at 270 milliseconds. After: modulepreload hints injected on hover at minus 150 milliseconds start all four fetches in parallel, so they complete before the click and the route renders at 30 milliseconds. click (t = 0) Before — serial discovery (renders at ~270 ms) reports.js chart.js grid.js vendor-d3.js render 270 ms After — modulepreload on hover (renders at ~30 ms) hover (t = −150 ms) reports.js chart.js grid.js vendor-d3.js render ~30 ms after click fetches finish pre-click Fetch waits on parent chunk parse Deepest tier (3rd RTT) Hinted fetch, parallel from hover

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 link or 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 recursive imports arrays) 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 modulepreload for the entire flattened list on pointerenter/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 on navigator.connection.effectiveType so 2g users 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 manualChunks grouping d3 into 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. Confirm build.modulePreload has not been disabled.
  • [ ] 7. Verify parallelism. Re-record the route change. All chunks should now share one start time (initiator link), Priority High, and exactly one request per URL — two rows for the same chunk means a crossorigin mismatch 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