Opening a route in a single-page app takes three sequential network round trips: the route chunk downloads, then executing it triggers a dynamic import of a chart module, which in turn dynamically imports a locale bundle. How do you confirm this chunk waterfall from a trace, and what are the ways to flatten it?
answer
- staircase in the network panel
- URL unknown until the parent runs
- depth costs more than size
- hint the children up front
- merge the always-needed small leaf
basics
~20 sConfirm it in a network trace: each chunk request starts only after the previous one finished and executed, forming a staircase. Flatten it by making child chunks discoverable up front with preload hints, starting the imports in parallel, or merging small children into the parent.
solid answer
~60 sThe symptom is a staircase in the network panel — each chunk's request start time lines up with the previous chunk's finish, with a gap for parse and execute in between. That is the signature of **late discovery**: the URL of a child chunk is not knowable until its parent has been fetched and run, so the browser cannot start it any earlier. Depth, not size, is the cost here — three 20 KB chunks in a chain are far worse than one 60 KB chunk on a high-latency connection. There are three fixes and you usually combine them. Emit `<link rel="modulepreload">` for the chunks a route is known to need so they download in parallel with the parent; many build tools generate these automatically from a dynamic import's static graph. Start the imports concurrently — kick off the child import at the top of the route module and await it later, rather than reaching it deep in a render path. And where a child is small, delete the boundary: merge it into the parent so there is nothing to discover. Then prefetch the whole route graph on navigation intent so the round trips happen before the click.
code
javascript · 10 lines// Kick the child chunk off at module scope so it overlaps with data fetching.
const chartPromise = import('./chart.js');
export async function render(el) {
const [data, { Chart }] = await Promise.all([
fetch('/api/series').then((r) => r.json()),
chartPromise,
]);
new Chart(el, data).draw();
}go deeper
Know that a dynamically imported chunk cannot start downloading until the code that imports it has already been downloaded and run, so nested lazy imports load one after another.
Explain late discovery as the mechanism and read the staircase in a network trace, including the parse-and-execute gap between requests. Be ready to describe modulepreload and starting an import early so fetches overlap.
Show a diagnosis that rules out bandwidth and server latency first, then a ranked remediation plan — hint known children, parallelise the starts, merge small always-needed chunks — and measure the improvement as chain depth on a high-latency profile.
Set the standard the team designs against: chunk-graph depth to first paint of a route as a tracked number, with hint generation automated in the build so correctness does not depend on an engineer remembering to add a link tag.
## Reading the trace Open a network trace of the navigation and sort by start time. A waterfall caused by chunk nesting has a distinctive shape: - `route.js` starts at T, finishes at T+120ms. - `chart.js` starts at roughly T+140ms — *after* the parent finished, plus a gap. - `locale.js` starts after `chart.js` finishes. The giveaway is that the start of each request is gated by the completion of the previous one, and the small gap between them is the parent's parse-and-execute time. Overlaying a main-thread trace makes it explicit: you see a script-evaluation task, and the next request begins inside or just after it. Contrast this with genuine bandwidth contention, where requests start together and finish slowly, or with server latency, where the gap is inside a single request's time-to-first-byte. Those have different fixes; misdiagnosing costs you a sprint. ## Why it happens A dynamic import's target is resolved by the bundler into a chunk URL that appears **inside** the parent chunk's code. Nothing in the HTML or in earlier responses mentions it. The browser's preload scanner cannot find it because it is not markup, and speculative fetching cannot guess it. So the earliest possible moment the request can begin is after the parent has been downloaded, parsed, and executed to the point of the import call. Each hop therefore costs at minimum one full round trip plus the parent's execution time. On a fast connection that is invisible; at 150 ms of latency, a three-deep chain is half a second of dead time before anything renders — during an interaction, which is exactly where users notice. This is why over-splitting can be counterproductive: the harm is not the number of files so much as the **depth** of the discovery chain. ## Fix 1 — make the children discoverable early `<link rel="modulepreload" href="/assets/chart.[hash].js">` tells the browser to fetch (and typically parse) a module ahead of the code that imports it. Emit these for the chunks a route is known to need, at the moment the route is requested, and the child downloads in parallel with the parent instead of after it. The staircase collapses toward a single round trip. The build already knows this graph — the static dependency set of a dynamically imported module is computable — and several bundlers generate the hint injection automatically for dynamic imports. When yours does not, you can append the link elements yourself as part of the route transition: ```js function preloadChunk(href) { const link = document.createElement('link'); link.rel = 'modulepreload'; link.href = href; document.head.append(link); } ``` The caveat is honesty: preload what the route will really use. Hints for chunks that go unused waste bandwidth and contend with what does matter. ## Fix 2 — start the requests concurrently If the parent knows it will need the child, do not wait until the child is reached in a code path: ```js // Late: discovery happens deep in a code path, after other work. async function render(el) { const data = await fetchData(); const { Chart } = await import('./chart.js'); // starts only now new Chart(el, data).draw(); } // Early: both requests are in flight from the first line. const chartPromise = import('./chart.js'); async function render(el) { const [data, { Chart }] = await Promise.all([fetchData(), chartPromise]); new Chart(el, data).draw(); } ``` The module-level `import()` starts the fetch as soon as the parent executes and overlaps it with data fetching. `import()` caches per module, so awaiting the same promise later costs nothing extra. ## Fix 3 — delete the boundary A chunk boundary earns its keep only if the code behind it is meaningfully large and genuinely optional. A 4 KB locale helper that the chart always needs is not optional: folding it into the chart chunk removes an entire round trip in exchange for 4 KB that was going to be downloaded anyway. Audit the small leaves of the chunk graph and merge the ones that are always used. A useful rule: measure the **depth** of the graph from entry to the last chunk needed to paint a route, and treat anything beyond two levels as a defect to justify. ## Fix 4 — move the cost off the interaction Even a flattened graph costs a round trip on first navigation. Warm it on intent — pointer-enter or focus of the link, or during idle time after the current route settles — so the chunks are already cached when the click happens. This does not reduce depth; it moves the whole staircase to a moment when nobody is waiting. ## What a strong answer includes Name late discovery as the mechanism, distinguish the waterfall's trace signature from bandwidth or server-latency problems, and give a ranked plan: preload hints for known children, concurrent starts, merging small always-needed chunks, and prefetch on intent — with a note that the number to track is chain depth on a high-latency profile, not total chunk count.
- Why can the browser's preload scanner not discover these chunks on its own?The scanner works over the HTML byte stream, looking for resource URLs in markup. A dynamically imported chunk's URL exists only as a string inside compiled JavaScript, so it is invisible until that script is fetched, parsed, and executed. That is the whole reason the waterfall exists, and why moving the URL into markup as a preload hint fixes it.
- How would you tell a chunk waterfall apart from a slow server?Look at where the time goes inside each request. A slow server shows a long time-to-first-byte within one request while others sit idle behind it. A chunk waterfall shows short, healthy requests whose start times are staggered, with main-thread script evaluation filling the gaps. A CPU-bound variant looks similar but the gaps are long tasks, not network time.
- What is the risk of solving this by preloading everything a route might need?Bandwidth and priority contention. Preload hints compete with resources that are actually on the critical path, so over-hinting can delay the main content while fetching chunks nobody uses. Hint only the modules the route reliably needs to render, and let genuinely conditional code stay lazy — or use a lower-urgency prefetch for it during idle time.
- Does flattening the graph mean you should stop splitting that route?No — it means the boundaries should be chosen for optionality, not habit. Keep splits where the code is large and genuinely conditional, and remove the ones that only add a hop for code the route always needs. The target is a shallow graph of a few meaningful chunks, not the largest number of small ones.
saying these in an interview costs you the question
- Blames total bundle size when the problem is request depth
- Assumes the browser can prefetch chunk URLs it has not parsed yet
- Thinks more, smaller chunks are always faster
- Preloads every possible chunk and calls it a fix
- Confuses the staircase with server latency or bandwidth limits