Skip to main content

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 import is 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 import cannot 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/catch or .catch(), because this is an ordinary Promise.

Quick example

javascript
// 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:

javascript
import(modulePath) .then(module => { // the module is loaded module.default(); }) .catch(err => console.error(err));
javascript
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:

javascript
// math.js export function add(a, b) { return a + b; } export const PI = 3.14;
javascript
const math = await import('./math.js'); console.log(math.add(2, 3)); // 5 console.log(math.PI); // 3.14

Since this is a regular Promise, errors are handled the usual way:

javascript
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:

javascript
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:

javascript
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:

javascript
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

ScenarioWhy it helps
Lazy loading componentsLoad a component only when the user navigates to that page
Translations and localisationImport just the language you need
Heavy dependenciesDefer large libraries (chart.js, moment, three.js)
Plugins and modular architectureWire in functionality on request
SSR and code splittingIn Next.js, Vite and Webpack the bundles are split automatically
Feature flags and A/B testsLoad different modules depending on a flag

How it works under the hood

Bundlers (Webpack, Rollup, Vite, esbuild) do three things:

  1. Analyse the import('./path') expressions in your code.
  2. Perform code splitting, breaking the project into separate chunks.
  3. Fetch the needed chunk over the network when import() is called.

Roughly what the build output looks like:

javascript
main.js // the main bundle vendors~chart.js // the shared library chunk chart.js // fetched on click

In React and Next.js this is wrapped in ready-made helpers:

javascript
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()

Criterionimport()require()
TypeAsynchronous (Promise)Synchronous
Works inESM (browser, Node.js)CommonJS (Node.js)
Dynamic pathYesLimited
Supports tree-shakingYesNo
Code splitting in bundlersYesNo

A summary of import() itself:

QuestionAnswer
What it doesLoads modules asynchronously, on demand
What it returnsA Promise with the module object
Where it worksES Modules (Node.js, browser)
What it is forLazy loading, code splitting, A/B tests, dynamic localisation
Syntaxconst module = await import('./file.js')

Common mistakes

  • Forgetting it is a Promise. const m = import('./a.js'); m.foo() will not work; you need await or .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 catch becomes 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 extra await in 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.