Topics

Jump to a topic.

Search questions

Find a question by title, tag, or summary.

How does dynamic import() work?

Questions in this chapter (7)
  • Draft
  • medium
  • Askedvery often
  • Verified October 2026

Answer

import(specifier) is an expression that returns a promise. It does the same three phases as a static import (load the graph, link, evaluate), but only when the expression runs, and it fulfills with the module's namespace object. Default exports are on .default.

How it differs from static import:

  • Run time, not link time. The specifier is any expression converted with ToString, so it can be computed or conditional. A static import is hoisted and runs before the module body.
  • Available everywhere. It works in classic scripts, CommonJS, and ES modules. In CommonJS it is the supported way to load an ES module that uses top-level await.
  • Asynchronous. The promise settles after the fetch, link, and evaluation, even if the module is already cached.
  • Cached. The same resolved module gives the same namespace object and is evaluated once. Two calls share one load.
  • Errors reject. A missing file, a link error (SyntaxError for a missing export), an evaluation error, or an unsupported option all reject the promise. Evaluation errors are remembered, so a second import() of the same module rejects with the same error.

It is syntax, not a function: import.call(...) and const f = import are SyntaxErrors, and there is no import binding to override.

Relative specifiers resolve against the module that contains the import() call, not the working directory. The second argument is an options object ({ with: { type: 'json' } }) for import attributes.

The main use is lazy loading: defer a heavy module until a feature is used, and let bundlers turn each import() into a separately downloaded chunk (covered in the tooling topic).

Example

console.log('start');
const button = { onclick: null };
button.onclick = async () => {
  const { default: run } = await import('./heavy.mjs');
  console.log(run());
};
console.log('before click');
await button.onclick();
await button.onclick();
// → start
// → before click
// → heavy evaluated
// → ran
// → ran

heavy.mjs logs heavy evaluated and exports a default function that returns 'ran'. The module body ran on the first call only.

Pitfalls

  • Forgetting that the result is the namespace. const run = await import('./x.mjs') gives an object; use .default or destructure.
  • Wrapping import() in try/catch but not using await. The promise rejects later, outside the try.
  • Expecting import() of a CommonJS file to expose live named exports. You get default plus whatever Node.js detects (see the ESM vs CommonJS question).
  • Writing import(`./${name}`) with user-controlled input. The specifier decides which code runs. Allow-list the names.

Follow-ups

Does import() run the module again on every call?
No. The host keys loaded modules by resolved URL (and module type), so the body runs once and later calls get the same namespace object.
Is import() a function?
No. It looks like a call but is its own syntax (ImportCall), so it can't be passed around, called, or reassigned.
How do you load several modules at once?
Promise.all([import('./a.mjs'), import('./b.mjs')]). Use allSettled to keep going when one fails.

Deep dive

How it works

The spec's EvaluateImportCall runs these steps in order:

  1. Take the referrer: the active script or module, or the current realm if there is none (for example an inline onclick="import('./x.mjs')" handler in HTML).
  2. Evaluate the specifier and options.
  3. Create a promise capability.
  4. ToString the specifier. If that throws, reject.
  5. Read options.with, which must be an object whose values are strings, and reject with TypeError if not. Reject with TypeError if the host does not support a given attribute key.
  6. Call the host hook HostLoadImportedModule with the referrer and a module request { specifier, attributes }.

Once the host delivers a Module Record, ContinueDynamicImport calls LoadRequestedModules to fetch the rest of the graph, then Link(), then Evaluate(). It resolves the promise with GetModuleNamespace(module) only after evaluation (including any top-level await) fulfills. Any step's failure rejects the same promise.

Because the checks in steps 4 and 5 produce rejections, not exceptions, bad arguments never throw synchronously:

const p = import(Symbol('x'));
const q = import('./heavy.mjs', 'oops');
console.log(p instanceof Promise, q instanceof Promise);
await Promise.allSettled([p, q]).then((r) => console.log(r.map((x) => x.reason.name)));
// → true true
// → [ 'TypeError', 'TypeError' ]

Edge cases

An evaluation error is stored on the module record, so every later import of it, static or dynamic, gets the same error and the body does not re-run.

The ordering is a promise's: the code after import() continues first.

Real-world scenario

Route-level code splitting is the common use: const Page = lazy(() => import('./Page.js')). Bundlers treat a static-string import() as a chunk boundary (tooling topic). Browsers fetch the module over the network (subject to caching and CORS), which belongs to the browser runtime topic.

Trade-offs

Dynamic import adds a network or disk round trip and an extra async step at the point of use, and the code can't be analyzed as precisely as static imports. Use it for code that is large and not needed at startup. Keep it out of code that always runs, where a static import is simpler and starts earlier.

Exercises

Log order

console.log(1);
import('./heavy.mjs').then(() => console.log(3));
console.log(2);

Partial failure

const results = await Promise.allSettled([import('./nope.mjs'), import('./heavy.mjs')]);
console.log(results.map((r) => r.status));