W3docs

JavaScript Dynamic import()

Learn JavaScript dynamic import() — load modules on demand for code splitting and lazy loading, with await and .then() syntax and error handling.

Dynamic imports in JavaScript are a feature introduced in ECMAScript 2020 (ES2020) that let you load a module at runtime, on demand, instead of all at once when the script first parses. Unlike the static import statement, the import() form is a function-like expression that returns a promise, so you can decide when and whether to load a module based on conditions, user actions, or routing. This guide covers the syntax, the most common use cases (code splitting, lazy loading, conditional loading), error handling, and a runnable end-to-end example.

This chapter assumes you are comfortable with ES modules and async/await.

Static import vs. dynamic import()

A static import statement must appear at the top level of a module and is fully resolved before any of the module's code runs. That makes it predictable and tooling-friendly, but it also means every statically imported module is fetched up front — even code the user may never reach.

import() is different in three important ways:

  • It is an expression, not a statement, so it can appear anywhere — inside an if, a function, or an event handler.
  • It accepts a dynamic specifier: the module path can be a variable or a computed string, not just a string literal.
  • It returns a promise that resolves to the module's namespace object (an object whose properties are the module's named exports, plus default).
// Static import — runs at parse time, must be top-level
import { formatDate } from './utils.js';

// Dynamic import — runs when this line executes, can be anywhere
const utils = await import('./utils.js');
utils.formatDate(new Date());

Because import() returns a promise, you handle the result with await (inside an async function) or with .then()/.catch():

// With await
const mod = await import('./utils.js');

// With .then() / .catch()
import('./utils.js')
  .then(mod => mod.formatDate(new Date()))
  .catch(err => console.error('Failed to load module:', err));

Reading exports from the module object

The resolved value is the module namespace object. Named exports are properties; the default export lives under the default key. Destructuring makes this cleaner:

// math.js exports: export function add(a,b){...}, export default function greet(){...}
const { add, default: greet } = await import('./math.js');

console.log(add(2, 3)); // 5
console.log(greet());   // "hello"
Info

await import(...) only works inside an async function or at the top level of an ES module (top-level await). In an ordinary <script> or a non-async function, use the .then() form instead.

Common use cases

Dynamic imports shine when part of your application is conditionally used or not immediately needed. Below are the most common patterns.

Code splitting

The most common use case for dynamic imports is code splitting: breaking your bundle into smaller chunks that load only when needed — typically when a route is visited or a feature is used. Below, a heavy script is fetched only after the user clicks, instead of inflating the initial page load.

button.addEventListener('click', function () {
    import('./heavyScript.js').then(mod => {
        mod.runHeavyTask();
    });
});

Because the click handler is synchronous, the .then() form is used here rather than await. The browser (or bundler) requests heavyScript.js only on the first click; subsequent clicks reuse the cached module.

Warning

Measure before you split. Adding too many tiny dynamic chunks can hurt performance — each one is a separate network round trip. Reserve dynamic imports for code that is genuinely large or rarely used.

Lazy loading components

Frameworks like React, Angular, and Vue use dynamic imports under the hood to lazy-load components — a component is only fetched when it first renders.

// Lazy loading a component in React
const LazyComponent = React.lazy(() => import('./LazyComponent'));

function App() {
    return (
        <React.Suspense fallback={<div>Loading...</div>}>
            <LazyComponent />
        </React.Suspense>
    );
}

React.lazy wraps the dynamic import, and React.Suspense shows the fallback until the chunk arrives. The user sees Loading... only for the brief moment the component is being fetched.

Advanced usage of dynamic imports

Conditional loading

Because import() is an expression, you can guard it with any condition — a feature flag, a user setting, the environment, or even the browser locale.

if (user.prefersAdvancedMode) {
    const advanced = await import('./advancedEditor.js');
    advanced.init();
}

Users who never enable advanced mode never download advancedEditor.js. You can take this further with a dynamic specifier — load a different module per locale, for example:

const locale = navigator.language.startsWith('fr') ? 'fr' : 'en';
const messages = await import(`./locales/${locale}.js`);
console.log(messages.default.greeting);
Warning

Bundlers like Webpack and Vite need to know which files might be loaded. A fully arbitrary specifier (e.g. a path built from user input) cannot be bundled. Keep the variable part of the path to a known directory and extension, as in the locale example above.

Build tools and Node.js support

When you write import('./module.js'), bundlers such as Webpack, Rollup, and Vite automatically emit a separate chunk and load it on demand — no extra configuration is usually needed. In the browser, native import() is supported in all modern browsers.

import() also works in Node.js (v12+), including inside CommonJS files, which is the standard way to load an ES module from CommonJS code:

// Loading an ESM module from a CommonJS file
async function run() {
    const { default: chalk } = await import('chalk');
    console.log(chalk.green('Loaded an ESM package from CommonJS'));
}
run();

Module metadata with import.meta

Inside a module you can read import.meta for contextual information. The most widely supported field is import.meta.url, which holds the current module's URL — handy for resolving sibling resources:

// Resolve a JSON file relative to the current module
const dataUrl = new URL('./data.json', import.meta.url);
const data = await import(dataUrl, { with: { type: 'json' } });

A full example: dynamic weather widget

The weather widget will dynamically load the module for fetching weather data only when the user requests it. This is an ideal scenario for dynamic imports, as it delays loading potentially heavy API interaction code until it's actually needed.

The example uses three files: an HTML page, an entry script, and the lazily-loaded module.

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Dynamic Weather Widget</title>
</head>
<body>
    <h1>Weather Widget</h1>
    <button id="loadWeather">Load Weather</button>
    <div id="weatherOutput">Click the button to load the weather.</div>

    <script src="index.js"></script>
</body>
</html>

index.js:

document.getElementById('loadWeather').addEventListener('click', async () => {
    const output = document.getElementById('weatherOutput');
    try {
        const weatherModule = await import('./weatherModule.js');
        const data = await weatherModule.loadWeather();
        output.textContent = `Weather: ${data.weather}`;
    } catch (err) {
        output.textContent = 'Failed to load weather data.';
    }
});

This code triggers a dynamic import on user interaction:

  1. Event listener: Attaches a click handler to the button.
  2. Dynamic import: Uses await import() to fetch the module only when clicked, keeping the initial bundle small.
  3. Error handling: The try...catch wraps both the import() and the data call, so a failed download or a rejected request shows the fallback message.

This approach helps make webpages efficient and responsive by loading resources only when necessary and providing immediate feedback to user interactions.

weatherModule.js:

export async function loadWeather() {
    // Simulated API call
    return new Promise(resolve => {
        setTimeout(() => {
            resolve({ weather: 'Sunny, 76°F' });  // Simulating weather data
        }, 1000);
    });
}

The function mimics fetching data from a remote source without needing a real API: it resolves after a one-second delay so you can see the deferred load in action.

Example explanation

  • HTML setup: Provides a button and a container for the output.
  • Dynamic import in action: Clicking the button triggers index.js to load weatherModule.js on demand.
  • Weather module: Simulates an API delay, showing how dynamic imports defer heavy or conditional logic until it is actually needed.

Common pitfalls

  • await outside a module or async function. Top-level await import() only works in ES modules; in plain scripts or non-async callbacks, use .then().
  • Forgetting .default. A module's default export is reached via the default property of the resolved object, not the object itself.
  • Fully dynamic paths. Bundlers cannot split a path they cannot analyze. Keep the literal part of the specifier (directory and extension) static.
  • Over-splitting. Each dynamic chunk is a separate request. Split large or rarely-used code, not every small helper.

Conclusion

Dynamic import() lets you load modules on demand, returning a promise that resolves to the module's namespace object. It powers code splitting, lazy-loaded components, conditional loading, and locale-aware imports — improving startup performance when used deliberately. Combine it with async/await and solid error handling, and lean on your bundler to turn each import() into an optimized chunk.

To go deeper, review ES modules: export and import, the modules introduction, and promises.

Practice

Practice
Which statements about JavaScript dynamic import() are correct?
Which statements about JavaScript dynamic import() are correct?
Was this page helpful?