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. It is the same blind spot that makes preload scanner misses so costly in single-page apps, one level further down the graph.

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.

Route change waterfall: serial stair-step chunk fetches versus parallel fetches warmed by modulepreload on hover 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 finish before the click and the route renders 30 milliseconds after it. click (t = 0) Before — serial discovery (270 ms) reports.js chart.js grid.js vendor-d3.js renders at 270 ms After — modulepreload on hover (30 ms) reports.js chart.js grid.js vendor-d3.js renders 30 ms after click hover (t = −150 ms) all four chunks land before the click tier 1 (at click) tier 2 tier 3 (deepest) hinted, 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. Drawn as a graph, the shape of the problem is a chain, not a fan — each column of the chunk graph is a column the browser cannot even name until the previous column’s bytes have been parsed.

Chunk dependency graph for the reproduction, showing which tier each chunk is discovered in and what it costs in round trips Four columns left to right. router.js is already loaded. It dynamically imports reports.9f3a.js, discovered at click time in tier one. reports imports chart.77b2.js and grid.4c11.js, discovered eighty-five milliseconds later in tier two. chart imports vendor-d3.a01f.js, discovered a further eighty-five milliseconds later in tier three. Three discovery waves add roughly 255 milliseconds before the route can render. already loaded tier 1 — at click tier 2 — +85 ms tier 3 — +170 ms router.js route table reports.9f3a.js route chunk chart.77b2.js feature chunk grid.4c11.js feature chunk vendor-d3.a01f.js shared vendor shipped in the initial bundle URL known from the route table named only after reports.js parses named only after chart.js parses 3 waves × 85 ms RTT ≈ 255 ms of sequencing before the route can render

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 — the traversal and its cycle guard are worked through in mapping Vite chunk graphs to modulepreload. 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.

Steps 1 and 7 read the same two Network columns, before and after. Initiator tells you who asked for the chunk, and Start tells you when. A serialized tier shows up as an initiator that is another chunk paired with a start time one round trip later; a flattened graph shows link in every initiator cell and one shared start time. Everything else in the panel is noise for this diagnosis.

Stylised DevTools Network rows showing the Initiator and Start columns before and after hover warm-up Two four-row tables with Name, Initiator, Priority, Start and Tier columns. Before, the initiators are router.js and then other chunks, and start times step 0, 92, 92, 205 milliseconds across three tiers. After, every row is initiated by link with the modulepreload hint and every start time is minus 150 milliseconds, one shared tier. What the Initiator column shows before and after the fix Before — every deeper chunk is initiated by another chunk Name Initiator Priority Start (ms) Tier reports.9f3a.js router.js High 0 chart.77b2.js reports.9f3a.js High 92 grid.4c11.js reports.9f3a.js High 92 vendor-d3.a01f.js chart.77b2.js High 205 Three start times, and initiators that are chunks rather than hints: three serialized tiers. After — the hover-injected hint initiates every chunk Name Initiator Priority Start (ms) Tier reports.9f3a.js link (modulepreload) High −150 chart.77b2.js link (modulepreload) High −150 grid.4c11.js link (modulepreload) High −150 vendor-d3.a01f.js link (modulepreload) High −150 One shared start time, one row per URL, and no chunk left initiating another: the shape step 7 asks for.

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