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
.defaultproperty of the resolved namespace — there is no destructuring shortcut that renames it for you, so writeconst { 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
| Aspect | Static import | Dynamic import() |
|---|---|---|
| Syntax | Declaration | Function-like expression |
| When evaluated | Before module body runs | When the call executes |
| Return value | Bindings (live) | Promise of namespace object |
| Placement | Top level only | Anywhere |
| Conditional | No | Yes |
| Specifier | Static string literal | Any expression |
| Effect on bundling | Part of main chunk | Creates 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/catchand 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.