skip to content

A page module statically imports a heavy charting library at the top. If you move it to `await import('./chart.js')` inside a button's click handler instead, what changes about what the browser downloads — and what happens if the user clicks that button five times?

level: middleimportance: must knowfreq 62%

answer

  1. out of the initial graph
  2. every call site is a boundary
  3. module map keyed by specifier
  4. evaluated once, five clicks or not
  5. first click pays the latency

basics

~20 s

The static import makes the library part of the initial module graph, so it is fetched and evaluated before the page code runs. Moving it into import() defers the fetch until the first click. Five clicks fetch and evaluate it once — later calls resolve from the module map.

solid answer

~50 s

A static import is part of the page module's dependency graph, so the engine or bundler pulls the library in up front: it is downloaded, parsed and evaluated before the page's own code executes, whether or not the user ever presses the button. Rewriting it as `await import('./chart.js')` inside the handler turns that call site into a **split point** — the library moves into a separately loaded unit that is only requested the first time someone clicks. The initial payload shrinks by the size of the library plus anything only it depends on. On five clicks the module is fetched and its body evaluated exactly **once**: the host keeps a module map keyed by the resolved specifier, so the second through fifth calls resolve with the same namespace object, no new request and no re-evaluation. What you do pay is latency on the *first* click, so the handler needs a pending state and a guard against a second click while the first load is in flight.

code

javascript · 24 lines
javascript
let chartModulePromise;

function loadChart() {
  // one promise, reused by every caller
  chartModulePromise ??= import('./chart.js');
  return chartModulePromise;
}

const button = document.querySelector('#show-chart');

// warm it on hover so the click feels instant
button.addEventListener('mouseenter', () => { loadChart().catch(() => {}); });

button.addEventListener('click', async () => {
  button.disabled = true;
  try {
    const { renderChart } = await loadChart();
    renderChart([1, 2, 3]);
  } catch (err) {
    console.error('chart failed to load', err);
  } finally {
    button.disabled = false;
  }
});

go deeper

for a junior

Recall that moving an import inside import() means the file is fetched only when that line runs, and that repeated calls do not fetch it again.

for a middle

Explain the mechanics: the call site is a boundary, the module map is keyed by resolved specifier, the body evaluates once, and the promise stays asynchronous even when cached.

for a senior

Demonstrate the operational side — pending states, guarding concurrent invocations, caching the promise, warming it ahead of the interaction, and handling rejection so a failed load is not a silent dead button.

for a principal

Be able to argue when deferring is a net loss: extra round trips on the critical path, more requests, and a worse interaction latency for a module too small to justify the boundary.

## What "in the initial graph" actually means When a module says `import { renderChart } from './chart.js'` at the top, that dependency is part of its static structure. Before the importing module's first statement runs, the engine resolves the specifier, fetches the file, and evaluates it — recursively, for the whole graph. The user pays for that library on page load even if they never open a chart. A bundler formalises the same idea: everything reachable through static imports from an entry point ends up in that entry's output, because it must all be available before the entry runs. ## What a split point is ```js button.addEventListener('click', async () => { const { renderChart } = await import('./chart.js'); renderChart(data); }); ``` Now `./chart.js` is not reachable statically from the entry. It is reachable only through a call that may or may not happen. Every `import()` call site is therefore a **boundary**: a bundler emits the modules behind it as a separate unit that is requested at runtime, and an unbundled ES-module setup simply fetches the file when the call runs. Either way the initial download no longer contains the library. The saving is not just the one file — it is the library and everything reachable only through it. If `chart.js` pulls in a date-formatting helper and a colour-scale module that nothing else uses, those move behind the boundary too. ## Five clicks, one load This is the part interviewers probe. The host maintains a **module map** keyed by the resolved specifier. The first `import('./chart.js')`: 1. resolves the specifier against the current module's URL/path, 2. finds no entry in the map, so fetches it, 3. instantiates and evaluates the module body, 4. fulfils the promise with the namespace object. The second through fifth calls find the existing entry and fulfil with the *same* namespace object. No second request, and — crucially — the module body does not run again, so any top-level side effect in `chart.js` happens once. That matches static imports: a module is evaluated once per realm regardless of how many times it is imported. One subtlety: caching does not make it synchronous. Even the fifth call resolves through a promise, never on the same tick. Code that reads exports must stay inside `await` or `.then()`. ```js async function load() { const a = await import('./chart.js'); // fetch + evaluate const b = await import('./chart.js'); // from the module map console.log(a === b); // true — the same namespace object } ``` ## What you traded away The cost lands on the first click. The user pressed a button and now waits for a network round trip plus parse and evaluate, on whatever connection they have. Three things follow: **Show a pending state.** The handler is asynchronous now; without feedback the UI looks frozen. **Guard concurrent clicks.** Five rapid clicks do not cause five loads, but they do cause five handler invocations that will each run your post-load work. Disable the control, or reuse a single promise: ```js let chartModule; function loadChart() { chartModule ??= import('./chart.js'); // one promise, reused return chartModule; } ``` Caching the *promise* rather than awaiting each time also makes it trivial to warm the load early — call `loadChart()` on hover or when the browser is idle, so by the time the click happens the promise is already fulfilled. **Handle failure.** The promise can reject: the request may fail, or the module body may throw while evaluating. Without a `catch` the click silently does nothing and you get an unhandled rejection. ## When this is worth doing Deferring is a win when the module is genuinely large relative to the page and genuinely optional — an editor, a chart library, a PDF renderer, an admin-only panel, a rarely used dialog. It is a loss when the module is small (you traded a few kilobytes of initial payload for a whole round trip) or when it is needed immediately on load anyway, in which case you have converted a parallel fetch into a sequential one and made the page slower. A good rule for the interview: lazily load things behind a *user intention* — a click, a route change, a scroll into view — and keep everything on the first-paint path static. ## Things people get wrong - "Lazy loading makes the app smaller." It does not reduce total bytes; it moves them out of the first request and may increase the total slightly. - "Each call re-downloads it." It does not; the module map handles that. - "Once loaded, I can import it synchronously." You cannot — the promise is always asynchronous. - Putting the `import()` inside a tight loop or a render path, so the promise machinery runs on every frame for a module that has long been resident.

  • Does a module's top-level side effect run again on the second dynamic import of the same specifier?
    No. Evaluation happens once per resolved specifier per realm, exactly as with static imports. The module map holds the finished module record, so later `import()` calls skip fetch and evaluation entirely and just hand back the namespace. If you need repeatable work, export a function and call it each time rather than relying on module body execution.
  • When is moving something behind import() actually a pessimisation?
    When the module is small, or when it is needed during initial render anyway. You have replaced a fetch that could have happened in parallel with the rest of the initial graph with one that starts only after your code runs — an extra round trip on the critical path. Deferring pays off for large, genuinely optional modules behind a user action.
  • How would you make the first click feel instant despite the network round trip?
    Start the load before the click and cache the promise: call `import()` on hover or focus, or during an idle period after first paint, storing the promise in a variable. The click then awaits an already-fulfilled promise. Keep a rejection handler on the warming call so a failed speculative load does not surface as an unhandled rejection.

saying these in an interview costs you the question

  • Says each click re-downloads the module
  • Claims lazy loading reduces total bytes shipped
  • Thinks a cached module can then be imported synchronously
  • Lazy-loads modules needed for first paint
  • Forgets the loading state and the rejection path

context