Dynamic import
A dynamic import is a call to the import() function, which returns a Promise and lets you load modules at runtime rather than at build time. Thanks to that, only what is needed immediately goes into the initial bundle, and the rest of the code is fetched on demand.
Theory
TL;DR
import()is a function-like expression that returns a Promise holding the module object.- A static
importis resolved at build time and always lands in the bundle; a dynamic one runs at runtime. - The module path can be a variable or a template string, which a static
importcannot do. - Bundlers use
import()as the cut point for code splitting. - Typical scenarios: lazy loading, localisation, plugins, heavy dependencies, A/B tests.
- Loading errors are caught with
try/catchor.catch(), because this is an ordinary Promise.
Quick example
// Dynamic import: the module is fetched only when this line runs
const module = await import('./utils.js');
module.sayHello();
// Static import for comparison: resolved at build time, always in the bundle
// import { sayHello } from './utils.js';Syntax and what import() returns
import() works both with .then() and with await:
import(modulePath)
.then(module => {
// the module is loaded
module.default();
})
.catch(err => console.error(err));const module = await import('./math.js');
const { sum } = module;
console.log(sum(2, 3));The Promise resolves with the module object, which carries every named export plus the default property:
// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14;const math = await import('./math.js');
console.log(math.add(2, 3)); // 5
console.log(math.PI); // 3.14Since this is a regular Promise, errors are handled the usual way:
try {
const module = await import('./missing.js');
} catch (err) {
console.error('Module loading failed:', err);
}Conditional loading and dynamic paths
You can pick the module depending on the situation, for example for localisation:
if (navigator.language.startsWith('fr')) {
const messages = await import('./lang/fr.js');
console.log(messages.default.hello);
} else {
const messages = await import('./lang/en.js');
console.log(messages.default.hello);
}The path may be a variable or a template string, which is impossible with a static import:
const theme = 'dark';
const module = await import(`./themes/${theme}.js`);
module.applyTheme();One caveat: the bundler (Webpack, for instance) still has to predict the path pattern, otherwise it will not include the needed files in the build. So part of the path must stay static, like ./themes/ above.
The classic case is loading on a user action:
button.addEventListener('click', async () => {
const { openModal } = await import('./modal.js');
openModal();
});The modal.js module is loaded only on the first click, not at site startup, which saves traffic and speeds up the launch.
When to use a dynamic import
| Scenario | Why it helps |
|---|---|
| Lazy loading components | Load a component only when the user navigates to that page |
| Translations and localisation | Import just the language you need |
| Heavy dependencies | Defer large libraries (chart.js, moment, three.js) |
| Plugins and modular architecture | Wire in functionality on request |
| SSR and code splitting | In Next.js, Vite and Webpack the bundles are split automatically |
| Feature flags and A/B tests | Load different modules depending on a flag |
How it works under the hood
Bundlers (Webpack, Rollup, Vite, esbuild) do three things:
- Analyse the
import('./path')expressions in your code. - Perform code splitting, breaking the project into separate chunks.
- Fetch the needed chunk over the network when
import()is called.
Roughly what the build output looks like:
main.js // the main bundle
vendors~chart.js // the shared library chunk
chart.js // fetched on clickIn React and Next.js this is wrapped in ready-made helpers:
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('../components/Chart'), {
ssr: false,
loading: () => <p>Loading...</p>,
});
export default function Page() {
return (
<div>
<h1>Data</h1>
<Chart />
</div>
);
}The <Chart> component is fetched only in the browser and only once the user opens the page.
Differences from require()
| Criterion | import() | require() |
|---|---|---|
| Type | Asynchronous (Promise) | Synchronous |
| Works in | ESM (browser, Node.js) | CommonJS (Node.js) |
| Dynamic path | Yes | Limited |
| Supports tree-shaking | Yes | No |
| Code splitting in bundlers | Yes | No |
A summary of import() itself:
| Question | Answer |
|---|---|
| What it does | Loads modules asynchronously, on demand |
| What it returns | A Promise with the module object |
| Where it works | ES Modules (Node.js, browser) |
| What it is for | Lazy loading, code splitting, A/B tests, dynamic localisation |
| Syntax | const module = await import('./file.js') |
Common mistakes
- Forgetting it is a Promise.
const m = import('./a.js'); m.foo()will not work; you needawaitor.then(). - A fully dynamic path. The bundler cannot analyse
import(userInput), so the chunk never makes it into the build. Keep a static directory prefix. - No error handling. The network can fail, and a rejected Promise without a
catchbecomes an unhandled rejection. - Dynamic imports everywhere. If a module is needed at startup anyway,
import()only adds an extra network round trip. Small utilities are better imported statically. - Calling it in a loop without thinking about cost. Repeating
import()for the same path hits the module cache, but the extraawaitin a hot loop still slows the code down. - Expecting
import()to hand back a named export directly. It returns the whole module object, so you need destructuring:const { sum } = await import('./math.js').
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.