skip to content

Dynamic import() and Code Splitting

Loading a module on demand with `import()`, which returns a promise for the module namespace and marks a natural code-splitting boundary. Interviewers ask how it differs from a static import and how lazy loading actually shrinks what ships up front.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In JavaScript, what does the expression `import('./math.js')` evaluate to, and how does that differ from a static `import ... from './math.js'` declaration?

level: juniorimportance: must knowfreq 72%

answer

  1. expression, not a declaration
  2. its value is a promise
  3. fulfils with the namespace object
  4. default export sits on .default
  5. specifier can be computed at runtime

basics

~20 s

import() is a syntactic form that starts loading a module at runtime and evaluates to a Promise for that module's namespace object. A static import declaration is resolved before any code in the file runs and binds the exported names directly.

solid answer

~50 s

`import()` is an expression, not a declaration. It kicks off loading of the module at the moment it runs and evaluates to a `Promise` that fulfils with the module's **namespace object** — the same object you would get from `import * as ns from './math.js'` — so named exports are `ns.add` and the default export is `ns.default`. Because it is an expression it can appear anywhere: inside a function, an `if`, a click handler, even in a classic script, and the specifier can be a computed string. A static `import` declaration is the opposite: it must be at the top level of a module, the specifier must be a string literal, and the engine resolves and evaluates the whole graph before the importing module's first statement runs. In practice you write `const { add } = await import('./math.js')` and remember that the default export needs destructuring as `{ default: fn }`.

code

javascript · 15 lines
javascript
// math.js
//   export function add(a, b) { return a + b; }
//   export default function multiply(a, b) { return a * b; }

async function run() {
  const ns = await import('./math.js');
  console.log(ns.add(2, 3));       // 5
  console.log(ns.default(2, 3));   // 6

  // the usual destructuring form
  const { add, default: multiply } = await import('./math.js');
  console.log(add(1, 1), multiply(2, 4)); // 2 8
}

run();

go deeper

for a junior

Know that import() returns a promise for the module namespace, that you usually write const { thing } = await import(path), and that the default export is reached as .default.

for a middle

Be ready to explain why the static form must be top-level with a literal specifier while import() can sit anywhere with a computed one, and what that implies about when the engine knows the dependency graph.

for a senior

Show judgment about which form to use: static by default for analysability, dynamic only when a module should genuinely stay out of the initial graph, and be explicit that adopting it makes the whole call path asynchronous.

for a principal

Own the guidance for the codebase — where dynamic loading is sanctioned, how teams avoid it leaking into hot paths, and the cost of making a previously synchronous API asynchronous just to load something lazily.

## Two different things that share a keyword JavaScript has two import forms, and they are not variants of one feature. A **static import declaration** — `import { add } from './math.js'` — is part of a module's static structure. Before a single line of your module runs, the engine parses it, collects every import declaration, fetches and instantiates the whole dependency graph, and links the bindings. That is why the specifier must be a literal string and why the declaration is only legal at the top level of a module: the engine has to know the graph without running anything. **Dynamic import** — `import('./math.js')` — is an *expression* evaluated at the point it appears in the code, like any other expression. Evaluating it starts the load and immediately produces a `Promise`. ## What the promise fulfils with The fulfilment value is the **module namespace object**: the same value `import * as ns from './math.js'` would give you. Each export is a property on it. ```js // math.js export function add(a, b) { return a + b; } export default function multiply(a, b) { return a * b; } // consumer async function run() { const ns = await import('./math.js'); ns.add(2, 3); // 5 — named export ns.default(2, 3); // 6 — the default export lives on .default const { add, default: multiply } = await import('./math.js'); } ``` The single most common beginner bug is `const Chart = await import('./chart.js')` followed by `new Chart()`. The awaited value is the namespace, never the default export, so that call fails. Destructure `{ default: Chart }` instead. The namespace object is prototype-less and effectively read-only: you read exports off it, you do not assign to it. ## `import()` is syntax, not a function It looks like a call, but `import` is not a value. All of these are syntax errors or fail: ```js const f = import; // SyntaxError import.call(null, './a.js'); // not a function object const g = import.bind(null); // no such thing ``` Only the call form `import(specifier)` is legal. (There is a second, related form, `import.meta`, which is a different piece of syntax entirely.) Everything else about it behaves like a normal expression: it has a value, it can be returned, passed around once evaluated, and chained with `.then()`. ## Where each one is legal | | static `import` decl | `import()` | |---|---|---| | position | top level of a module only | any expression position | | specifier | string literal only | any expression producing a string | | conditional | no | yes | | available in classic scripts | no | yes | | result | live bindings, available before your code runs | a `Promise` for the namespace | The conditional part is what makes it useful: you can load a polyfill only when a feature is missing, load a locale file chosen at runtime, or load an editor component only when the user opens the editor. ```js if (!('IntersectionObserver' in window)) { await import('./polyfills/intersection-observer.js'); } const locale = navigator.language.startsWith('fr') ? 'fr' : 'en'; const { messages } = await import(`./locales/${locale}.js`); ``` ## Timing Even when the module is already loaded and evaluated, the promise still settles asynchronously — you cannot read the exports on the same synchronous tick. That means any code path using `import()` is asynchronous end to end: the function containing it becomes `async`, and its callers have to deal with a promise. Repeat calls do not re-fetch or re-evaluate. The host keeps a module map keyed by the resolved specifier, so the second `import('./math.js')` resolves with the very same namespace object as the first, and the module body runs exactly once. ## Rejection Because it is a promise, failures are rejections rather than thrown exceptions at the call site. A network failure, a 404, a specifier that cannot be resolved, or an exception thrown while the module body evaluates all reject the promise. An unhandled one becomes an unhandled rejection instead of a visible error, which is why lazy-loading code is normally wrapped in `try`/`catch`. ## When to reach for it Use the static form by default — it is analysable, it is what the engine and tooling optimise for, and it makes the dependency obvious. Reach for `import()` when the module genuinely should not be part of the initial graph: it is large and rarely used, it is chosen at runtime, or it is only needed after a user action.

  • Someone writes `const Chart = await import('./chart.js'); new Chart();` and gets an error. What went wrong?
    `await import()` fulfils with the module namespace object, not with the default export. `Chart` here is the namespace, which is not callable or constructible. The fix is `const { default: Chart } = await import('./chart.js')`, or `const ns = await import('./chart.js'); new ns.default()`. This trips people up because the static form `import Chart from './chart.js'` does unwrap the default for you.
  • Is `import` a function value you can pass around or bind?
    No. `import(...)` is dedicated syntax, so `import` is not an expression on its own: `const f = import` is a SyntaxError, and there is no `import.call` or `import.bind`. You can only write the call form. If you need to pass loading around, wrap it in an arrow function — `const load = () => import('./math.js')` — and pass that.
  • If the same module is dynamically imported twice, does it load and run twice?
    No. The host keeps a module map keyed by the resolved specifier, so the first call fetches and evaluates the module body once; every later call resolves with the same namespace object without a new request or a second evaluation. The promise is still asynchronous, though — you never get the exports on the same synchronous tick.

A static import is a packing list handed over before the truck leaves; import() is phoning for a delivery mid-journey and waiting for it to arrive.

saying these in an interview costs you the question

  • Says import() returns the module object synchronously
  • Assumes await import() hands back the default export
  • Thinks import can be assigned or bound like a function
  • Believes each import() call re-downloads and re-runs the module
  • Claims dynamic and static import are interchangeable syntax

context

open as a page

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%

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.

open as a page

What is `import.meta` in an ES module, what does `import.meta.url` typically give you, and why does the same line throw a SyntaxError when the file is loaded as a classic script?

level: middleimportance: should knowfreq 38%

basics

~20 s

import.meta is a per-module object the host fills with metadata about the currently running module. In browsers and Node ESM its url property holds the module's own URL, useful for resolving sibling assets. It is module-only syntax, so a classic script rejects it at parse time.

open as a page

A single-page app lazy-loads a route with `await import('./routes/settings.js')`. In production, some users hit a blank screen because that load fails. How does `import()` surface the failure, and how would you handle it in the code?

level: seniorimportance: should knowfreq 45%

basics

~20 s

import() reports failure by rejecting its promise — nothing is thrown at the call site. Wrap the await in try/catch, distinguish a failed fetch from an error thrown while the module evaluates, show a real error state, and recover from a stale-deploy 404 with a full page reload rather than a bare retry.

open as a page

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?

level: principalimportance: should knowfreq 33%

basics

~20 s

Good 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.

open as a page