Skip to content
JavaScript js modules 4 min read

Dynamic import()

Static import statements are resolved before your code runs, which means every dependency is fetched and evaluated up front. Dynamic import() flips that model: it is a function-like expression you call at runtime that returns a promise resolving to a module’s namespace object. This lets you defer loading code until you actually need it, splitting large bundles into smaller chunks and shrinking the work the browser does on first paint.

How dynamic import() works

Unlike the static import ... from '...' declaration, import() looks and behaves like a function call. It can appear anywhere an expression can — inside functions, conditionals, loops, or event handlers — and it always returns a promise. When that promise resolves, you receive the module namespace object: the same shape you would get from a import * as ns from '...'.

// math.js
export const add = (a, b) => a + b;
export default function multiply(a, b) {
  return a * b;
}

// main.js
const module = await import('./math.js');
console.log(module.add(2, 3));          // named export
console.log(module.default(2, 3));      // default export

Output:

5
6

Because it returns a promise, you can consume it with await or with .then(). Destructuring keeps things tidy when you only need a couple of exports:

const { add } = await import('./math.js');
console.log(add(10, 5)); // 15

The default export is always available under the .default property of the resolved namespace — there is no destructuring shortcut that renames it for you, so write const { default: multiply } = await import('./math.js').

Lazy loading and code splitting

The biggest payoff is code splitting. Bundlers such as Vite, webpack, and esbuild treat each import() call as a split point, emitting a separate chunk that is only downloaded when the call executes. A heavy feature — a chart library, a rich text editor, a date picker — no longer weighs down your initial load.

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

chartButton.addEventListener('click', async () => {
  const { renderChart } = await import('./chart.js');
  renderChart(document.querySelector('#canvas'));
});

The chart.js chunk (and its dependencies) is fetched the first time the user clicks the button. Subsequent calls reuse the already-evaluated module from the module cache, so there is no second network request.

Conditional imports

Because import() is just an expression, you can load different modules based on runtime conditions: feature flags, the user’s locale, the environment, or capability detection. This is impossible with static imports, which must sit at the top level and are evaluated unconditionally.

async function loadLocale(lang) {
  const supported = ['en', 'fr', 'de'];
  const code = supported.includes(lang) ? lang : 'en';
  const { messages } = await import(`./locales/${code}.js`);
  return messages;
}

const strings = await loadLocale(navigator.language.slice(0, 2));
console.log(strings.greeting);

You can also gate a polyfill behind a feature check, downloading it only for browsers that need it:

if (!('IntersectionObserver' in window)) {
  await import('./polyfills/intersection-observer.js');
}

Avoid fully dynamic specifiers like import(userInput). Bundlers cannot statically analyze an arbitrary string, so they either bundle every possible match (template literals with a fixed prefix) or fail entirely. Keep a static prefix and suffix as shown above.

Static vs. dynamic import

AspectStatic importDynamic import()
SyntaxDeclarationFunction-like expression
When evaluatedBefore module body runsWhen the call executes
Return valueBindings (live)Promise of namespace object
PlacementTop level onlyAnywhere
ConditionalNoYes
SpecifierStatic string literalAny expression
Effect on bundlingPart of main chunkCreates a split chunk

Pairing with top-level await

In an ES module you can use await at the top level — no wrapping async function required. This pairs naturally with import() when a module’s setup genuinely depends on another module being ready before it exports anything.

// config.js (an ES module)
const env = process.env.NODE_ENV ?? 'development';
const { config } = await import(`./config.${env}.js`);

export default config;

Any module that statically imports config.js will wait for that top-level await to settle before its own body runs. Use this deliberately: top-level await delays the entire module graph that depends on it, so reserve it for true initialization needs rather than convenience.

Best practices

  • Reach for import() at natural interaction or route boundaries (button clicks, route changes, viewport entry) so chunks load right before they are needed.
  • Keep specifiers analyzable: use string literals or template literals with a fixed prefix and suffix so the bundler can emit chunks correctly.
  • Handle rejection — network failures or a missing chunk reject the promise. Wrap calls in try/catch and show a fallback.
  • Prefetch likely-next chunks during idle time (for example via requestIdleCallback) to hide latency without bloating the initial load.
  • Prefer static imports for code that always runs on startup; dynamic imports add a microtask and a potential network round trip.
  • Use top-level await sparingly, since it blocks every dependent module until it resolves.
Last updated June 1, 2026
Was this helpful?