JavaScriptDynamic Imports

Dynamic Imports

Static import statements are great for code you always need — but sometimes you want to load a module only when the user reaches a certain page, clicks a feature, or matches a condition. The import() expression (note the parentheses) lets you load a module at runtime. It returns a Promise that resolves to the module's namespace object.

Syntax: import as a function

JS
// Static — resolved at parse time, top of the file.
import { greet } from "./utils.js";

// Dynamic — an expression, anywhere code can run.
const mod = await import("./utils.js");
mod.greet("Ada");

// Or, destructure right away:
const { greet } = await import("./utils.js");
greet("Ada");

A few things to notice:

  • import("...") looks like a function call, but it is actually a built-in syntactic form. You cannot reassign it or call apply on it.

  • It returns a Promise for the module namespace — the same object you would get from import * as ns.

  • The path can be any expression, not just a literal — import(./locales/${lang}.js) is valid.

  • Modules are still cached: importing the same path twice returns the same module.

Code splitting and lazy loading

Bundlers (Vite, webpack, esbuild, Rollup) recognise import() as a hint: "split this chunk out, load it later." Your initial bundle stays small, and the heavy code only ships when it is needed.

Lazy-load a 200 KB chart library

JS
async function showChart() {
  const { Chart } = await import("./heavy-chart.js");
  const c = new Chart(document.querySelector("#canvas"));
  c.render();
}

document.querySelector("#open-chart")
  .addEventListener("click", showChart);

Until the user clicks the button, heavy-chart.js is never fetched. Click → network request → parse → execute → render. The first load of the page stays fast.

Load on demand from a condition

Load a polyfill only if needed

JS
if (!("IntersectionObserver" in window)) {
  const { polyfill } = await import("./io-polyfill.js");
  polyfill();
}

Localise on demand

JS
async function loadMessages(lang) {
  // The string is dynamic — bundlers usually pre-emptively split
  // every file in the matched folder into its own chunk.
  const mod = await import(`./locales/${lang}.js`);
  return mod.default;
}

const messages = await loadMessages(navigator.language.slice(0, 2));
console.log(messages.greeting);
Then-chaining, no await needed

import() is a regular Promise — you don't have to use await.

JS
button.addEventListener("click", () => {
  import("./editor.js")
    .then(({ openEditor }) => openEditor())
    .catch(err => console.error("Failed to load editor:", err));
});

Handling .catch matters — network errors, missing files, or syntax errors inside the loaded module all reject the Promise.

Error handling

JS
async function loadOptional() {
  try {
    const mod = await import("./optional.js");
    mod.start();
  } catch (err) {
    console.warn("Optional feature not available:", err);
  }
}
Common pitfalls
A failed dynamic import does **not** stop the rest of the page. That's the point. But it does mean any UI you've optimistically rendered must be able to recover when the module never arrives — show a fallback, retry, or surface an error.
Where dynamic imports shine
  • Routes. Load each page of an SPA only when the user navigates to it. React, Vue and Svelte routers all wrap import() for you.

  • Modals and rare UI. A complex date picker, emoji panel, or video editor that only one in twenty users opens.

  • Optional features. Admin-only screens, premium-only modules, A/B-test variants.

  • Polyfills. Ship the polyfill only to browsers that need it.

  • Server-rendered apps. On Node, dynamic import() is the standard way to require an ESM-only library from CommonJS code.

What you get back

The resolved value is the module's namespace object — the same shape as import * as ns.

math.js

JS
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export default "default value";

caller.js

JS
const ns = await import("./math.js");

console.log(ns.PI);          // 3.14159
console.log(ns.add(1, 2));   // 3
console.log(ns.default);     // "default value"
3.14159
3
default value
Bundler hints
Many bundlers accept magic comments inside the import call to control chunk names and prefetching. For example, webpack: `import(/* webpackChunkName: "editor" */ "./editor.js")`. Vite uses a slightly different syntax. Check your bundler's docs — they make the network panel much easier to read in production.

Dynamic imports are the cornerstone of modern front-end performance: ship the smallest possible first bundle, then load the rest just in time. Combine them with route-level splitting and you can keep huge apps feeling snappy.