When deciding where to put `import()` split points in an application, what makes a call site a good boundary, and what goes wrong when a team splits too aggressively?
answer
- not fewer bytes, different timing
- big and genuinely optional
- boundaries you can warm early
- nested lazies become a waterfall
- asynchrony leaks up the call path
basics
~20 sGood boundaries sit where a module is both large and genuinely optional, gated behind a user intention the app can anticipate. Over-splitting fragments code into many small units, creates sequential request waterfalls when one lazy module lazily imports another, and shifts latency onto interactions.
solid answer
~60 sA split point earns its place when the code behind it is **large relative to the page** and **genuinely conditional** — an editor, a chart or PDF library, an admin panel, a rarely visited route — and when the app can predict the need slightly before it happens, so the load can be warmed on hover, focus or idle rather than started at the moment of the click. Split points that fail those tests cost more than they save. Splitting something small trades a few kilobytes of initial payload for a whole round trip. Splitting something on the first-paint path converts a fetch that would have run in parallel with the initial graph into a sequential one. Worst is **nesting**: a lazily loaded module that itself lazily imports its dependencies creates a waterfall where each round trip only begins after the previous one finishes, so total latency is the sum, not the max. The judgment call is that you are not reducing bytes — you are choosing *when* the user pays for them, and moving cost onto an interaction is only a win if the interaction is unlikely or if you can pay it early.
code
javascript · 11 lines// GOOD: one coarse boundary; the route's own deps are static inside it.
// routes/editor.js does `import { toolbar } from './toolbar.js'` statically,
// so one round trip brings the whole feature.
let editorPromise;
const loadEditor = () => (editorPromise ??= import('./routes/editor.js'));
navItem.addEventListener('pointerenter', () => loadEditor().catch(() => {}));
navItem.addEventListener('click', async () => {
const { mount } = await loadEditor();
mount();
});go deeper
Recall the basic rule of thumb: defer big, rarely used things behind a user action, and keep anything needed for the first screen statically imported.
Explain the mechanics behind the advice — each call site is a boundary, nested boundaries serialise round trips, and a cached promise lets you start the load before the click.
Show that you evaluate boundaries with data: crossing rate, load time at the tail, and actual initial-payload reduction, plus the operational consequences of many independently versioned units.
Own the policy — static by default, coarse boundaries at architectural seams, an enforceable payload and boundary budget, and a clear position that splitting redistributes cost rather than removing it.
## The thing you are actually trading Lazy loading does not make an application smaller. The same code still ships; sometimes slightly more, because splitting duplicates a little shared plumbing and adds a request. What changes is **when** each byte is paid for, and by which user. That reframing drives every decision. A split point moves cost from "everyone, on first load" to "the subset who take this action, at the moment they take it". It is a win when the subset is small and the moment is forgiving. It is a loss when nearly everyone takes the action, or when the moment is one where the user is already waiting. ## What makes a call site a good boundary Four tests, roughly in order of importance: **Is it big?** Below some threshold the round trip dominates the payload. A module of a few kilobytes is not worth a request; a library measured in hundreds is. **Is it genuinely optional?** If 90% of sessions open the thing, you have deferred work almost everyone needs and added latency to the path that matters. Reserve boundaries for genuinely conditional code: admin-only tools, a rich editor, an export flow, an error-reporting fallback, a locale bundle where only one of many is needed. **Does the boundary cut cleanly?** A good boundary has few shared dependencies with the rest of the app. If the lazy module and the initial payload share most of their dependency tree, you have split the entry point but not the weight. **Can you predict the need?** Boundaries you can warm are far better than boundaries you cannot. `import()` on hover, on focus, on route-adjacent navigation, or during idle time turns a blocking wait into a load that has already fulfilled by click time. Caching the *promise* is what makes this trivial: ```js let editor; const loadEditor = () => (editor ??= import('./editor.js')); button.addEventListener('mouseenter', () => loadEditor().catch(() => {})); button.addEventListener('click', async () => { const { mount } = await loadEditor(); mount(); }); ``` ## The failure modes of over-splitting **Waterfalls.** This is the real damage. If a lazily loaded route module itself lazily imports its panel, and the panel lazily imports its chart, then each request can only start once the previous one has been fetched and evaluated. Three round trips in sequence, on a slow connection, is a visibly broken interaction. The fix is to make the *inner* dependencies static, so the outer boundary pulls its whole subtree in one go, and to keep boundaries shallow — a flat set of split points at route or feature level rather than a tree of them. **Latency where the user is watching.** Deferring work to a click means the user has expressed intent and is now waiting. Deferring to first paint means they were waiting anyway. Splitting the wrong side of that line trades invisible cost for visible cost. **Request overhead multiplying.** Many small units mean many requests, each with its own overhead and each needing to be parsed and evaluated separately. Past a point, the aggregate is slower than one larger unit even though the byte count is similar. **Caching churn.** Each boundary is a separately versioned artefact. Fine-grained splitting means a small change invalidates several units instead of one, and every unit is another thing that can go missing after a deploy while an old page is still open. **Asynchrony leaking upward.** Every `import()` makes its call path asynchronous. Push boundaries deep into the code and a function that used to return a value now returns a promise, its callers become `async`, and error handling has to be threaded through all of it. Boundaries belong at architectural seams — routes, feature entry points — where the code is already asynchronous, not in the middle of a utility. **Rejection surface.** Every boundary is a load that can fail independently. Ten boundaries is ten places needing an error path, or ten ways to get a dead control. ## How to reason about it as a lead Set the default: **static unless argued otherwise**. Static imports are simpler, analysable, and free of the failure modes above. Require a reason for each boundary, and prefer a small number of coarse boundaries — one per route or per major optional feature — over many fine ones. Measure rather than guess. The questions worth answering are: what fraction of sessions actually load each lazy unit, how long does that load take at the 95th percentile, and how much did initial payload actually fall when the boundary was introduced. A boundary that 95% of sessions cross is a boundary that should not exist. One that 3% cross and that removes a large library is doing its job. Budget it. "Initial payload under X, no more than N boundaries on any single interaction path" is a constraint a team can hold, and it prevents the drift where every new feature adds another lazy layer because that is the local habit. Finally, keep the *interaction* honest: pair each boundary with a pending state and a bounded failure path, and warm it wherever intent is observable. A split point that is warmed on hover and falls back gracefully is a genuinely better experience; the same split point with no warming and no error state is a slower, more fragile app that merely scores better on an initial-payload metric.
- How do you tell whether an existing split point is earning its keep?Measure two things: what share of sessions actually cross it, and what it removed from the initial payload. A boundary crossed by most sessions is pure added latency and should be inlined. A boundary that few sessions cross but that removes a large, cleanly separable library is doing exactly its job. Add the 95th-percentile load time for the unit — a small saving that costs seconds on a slow connection is a bad trade.
- A team reports that lazy routes feel slow even though the initial payload dropped. What would you look for first?Nested boundaries. If the lazy route lazily imports its own pieces, the round trips serialise and total latency is their sum. Make the inner imports static so one boundary fetches the whole subtree, and check whether anything is being warmed — starting the load on hover or focus rather than on click usually removes the perceived delay entirely.
- Why is pushing `import()` deep into utility code a design problem rather than just a performance one?Because it makes the call path asynchronous. A helper that returned a value now returns a promise, every caller becomes `async`, and each one inherits a new failure mode to handle. That churn spreads far beyond the module you wanted to defer. Boundaries belong at seams that are already asynchronous — route entry, feature activation — where the shape does not change.
- What guardrail would you put in place so split points do not proliferate?A budget the team can check: a cap on initial payload plus a limit on how many boundaries any single interaction path may cross, enforced in CI so a regression is visible at review time. Pair it with a written default — static unless justified — so adding a boundary is a decision someone argues for, rather than the local habit every new feature copies.
saying these in an interview costs you the question
- Says lazy loading reduces total bytes shipped
- Splits every component or utility by default
- Nests lazy imports inside lazy modules
- Ignores that the first click pays the round trip
- Judges success by initial payload alone