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
// 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 callapplyon 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
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
if (!("IntersectionObserver" in window)) {
const { polyfill } = await import("./io-polyfill.js");
polyfill();
}Localise on demand
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.
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
async function loadOptional() {
try {
const mod = await import("./optional.js");
mod.start();
} catch (err) {
console.warn("Optional feature not available:", err);
}
}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
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export default "default value";caller.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
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.