How to Fix Request Waterfalls Caused by Nested Dynamic Imports
This walkthrough extends Dynamic Imports and Route-Based Splitting within JavaScript Bundle Optimization & Code Splitting. Splitting reduced the initial bundle from 480KB to 160KB, and navigation got slower. The route chunk imports a data layer chunk, which imports a chart library chunk, and each one can only be discovered after the previous has downloaded and executed. Three sequential round trips on a 170ms connection is over half a second of nothing happening.
Code splitting trades bytes for round trips. The fix is to keep the byte saving while removing the serialisation.
Rapid Diagnosis: Spot the Staircase
- Look at the Network waterfall shape. Chunks that start in a descending staircase, each beginning as the previous ends, are dependent — not parallel.
- Read the initiator chain. Click a chunk and check Initiator: if it is another chunk rather than the document or the entry, you have nesting.
- Count the hops to first render. Any route needing more than two sequential chunk fetches will feel slow regardless of total size.
- Check whether the leaf is small. Serialising a round trip to fetch
4KBis the clearest case for inlining it into its parent.
Root Cause Analysis
1. Discovery is sequential by construction. The bundler cannot emit a request for a chunk whose import lives inside another chunk's code — it must download and execute the parent first. Every level of nesting is one more round trip.
2. Over-splitting small modules. A 4KB helper in its own chunk costs a full round trip to save four kilobytes. Below roughly 20KB, the round trip usually dominates.
3. Import-on-render inside the component. A dynamic import in a component body executes only after the parent has rendered, adding the render pass to the chain.
4. Awaiting imports in series. await import(a); await import(b); serialises two independent fetches for no reason — a one-character fix with a large effect.
Step-by-Step Resolution
1. Merge leaves that are too small to deserve a chunk.
// vite/rollup: keep small shared helpers with their consumer.
export default {
build: { rollupOptions: { output: {
manualChunks(id) {
if (id.includes('node_modules/chart.js')) return 'charts'; // big, shared
return undefined; // everything else inlines
},
experimentalMinChunkSize: 20_000 // fold anything smaller into its parent
} } }
};
// trade-off: bigger chunks mean more re-downloaded bytes when any part changes.
// With content-hashed filenames and long cache lifetimes that cost is paid
// rarely; a round trip is paid on every cold navigation.
Expected outcome: the chain shortens by one hop per merged leaf, typically 120–200ms each on mobile.
2. Hoist the nested import to the parent and fetch in parallel.
// Before: charts.js is discovered only after data-layer.js executes.
const { loadData } = await import('./data-layer.js'); // which imports charts
// After: both requests start together.
const [{ loadData }, { renderChart }] = await Promise.all([
import('./data-layer.js'),
import('./charts.js')
]);
// trade-off: you now always fetch charts.js, even on the paths that would not
// have needed it. Only hoist when the leaf is needed on the common path —
// otherwise keep the lazy import and preload it instead (step 3).
Expected outcome: two round trips collapse into one, cutting the chain's wall-clock roughly in half.
3. Preload the next hop while the current one executes. Where the leaf is genuinely conditional, start it early without awaiting it.
// Warm the next chunk during the parent's execution, without blocking on it.
const chartsPromise = import('./charts.js'); // fire, do not await
const { rows } = await loadData();
if (rows.length) {
const { renderChart } = await chartsPromise; // already in flight
renderChart(rows);
}
// trade-off: an un-awaited import that is never used still downloads. Keep the
// speculative fetch to one hop ahead, and skip it when navigator.connection
// reports saveData.
4. Prefetch route chunks on intent, not on load. Hover, focus or viewport entry gives you the chunk before the click without competing with the current page — the same technique described for route-level splitting in Next.js.
const seen = new Set();
document.addEventListener('pointerenter', (e) => {
const a = e.target.closest?.('a[data-route-chunk]');
if (!a || seen.has(a.dataset.routeChunk)) return;
seen.add(a.dataset.routeChunk);
const l = document.createElement('link');
l.rel = 'modulepreload'; l.href = a.dataset.routeChunk;
document.head.append(l);
}, { capture: true, passive: true });
// trade-off: modulepreload parses the module graph as well as fetching it,
// which costs main-thread time on hover. On a dense navigation menu that adds
// up — gate it to links the pointer rests on for a moment.
Verification
Re-record the navigation and check the waterfall shape rather than the total bytes. Success looks like chunks starting at the same moment, not a staircase, and time-to-interactive on the route dropping by roughly the round trips you removed.
Assert the structural property in CI so a new nested import cannot quietly reintroduce the chain:
// Fail the build if any route needs more than two sequential chunk fetches.
import { readFileSync } from 'node:fs';
const graph = JSON.parse(readFileSync('dist/stats.json', 'utf8'));
const depth = (id, seen = new Set()) => {
if (seen.has(id)) return 0;
seen.add(id);
const kids = graph.chunks[id]?.dynamicImports ?? [];
return kids.length ? 1 + Math.max(...kids.map((k) => depth(k, seen))) : 0;
};
for (const entry of graph.entrypoints) {
if (depth(entry) > 2) throw new Error(`chunk chain too deep: ${entry}`);
}
// trade-off: depth is a proxy — two deep-but-tiny hops may be fine while one
// huge chunk is not. Pair it with a per-chunk size budget rather than relying
// on either signal alone.
In the field, watch the route's interaction-to-render time rather than initial LCP: waterfall damage from nested imports lands on navigation, which page-load metrics do not capture at all.
FAQ
Is splitting into more chunks always better for caching?
No. Finer chunks improve cache reuse when only one part changes, but each additional chunk on the critical path costs a request and, if it is discovered late, a round trip. The balance depends on how often each part changes: split along genuine change boundaries — vendor versus app, rarely versus frequently updated — not by module count.
Does HTTP/2 multiplexing make waterfalls harmless?
It removes the connection cost of extra requests, not the dependency. A chunk that cannot be discovered until its parent executes still waits a full round trip, no matter how many requests share the connection. Multiplexing makes parallel fetching cheap; it does nothing for sequential discovery, which is what nesting creates.
Should I inline tiny chunks entirely?
Usually yes, below roughly 20KB on the critical path. The break-even is a comparison between the transfer saving and the round-trip cost, and on a mobile connection a round trip is worth far more than a few kilobytes. Keep small modules separate only when they are genuinely conditional and rarely needed.
How deep is too deep for a chunk chain?
Two levels is the practical ceiling on a mobile connection: the entry plus one dependent fetch. Each additional level adds a full round trip that no amount of bandwidth removes, because the request cannot be issued until the previous chunk has executed. If a route genuinely needs three tiers of code, hoist the middle one into the entry or preload it — the byte cost of a slightly larger parent is almost always cheaper than the latency of another hop.
Does server-side rendering hide this problem?
It hides the paint symptom and keeps the interaction one. Server rendering means the route's HTML arrives without waiting for any chunk, so the page looks complete while the chain is still resolving — and the controls in it stay inert until the last chunk lands. That is arguably worse for the user than a visible loading state, because the page invites interaction it cannot yet honour. Measure interaction readiness, not first paint, on server-rendered routes.
Should I prefetch every route chunk on page load?
No. Prefetching consumes bandwidth that the current page still needs, and on a mobile connection a background chunk download measurably delays the LCP resource. Prefetch on intent — pointer entry, focus, or a link scrolling into view — and skip it entirely when the connection reports reduced-data mode. That gets you most of the perceived benefit with none of the contention.
Related
- Dynamic Imports and Route-Based Splitting — choosing the split boundaries in the first place.
- Vite vs Webpack bundle splitting performance — how each bundler groups modules by default.
- Resource Hints & Early Hints — starting the first chunk before the parser reaches it.