Dynamic import() in JavaScript: Code-Splitting Explained
JavaScript's dynamic import() loads a module on demand and returns a promise, letting you split bundles and defer code until it's actually needed.
Dynamic import() is a function-like form of JavaScript’s module import that loads a module at runtime and returns a promise, instead of loading it eagerly when the file is parsed. It’s the mechanism behind most JavaScript code-splitting: instead of shipping every module a page might ever need in one bundle, you fetch a module only when the code path that needs it actually runs.
Static import vs. dynamic import()
The import statement you’re used to at the top of a file is static — the specifier has to be a fixed string literal, and the module graph is resolved before any of your code executes:
import { formatDate } from "./format-date.js";
Dynamic import() looks similar but behaves completely differently. It’s a function call, it can take a computed expression, and it returns a promise that resolves to the module’s namespace object:
button.addEventListener("click", async () => {
const { formatDate } = await import("./format-date.js");
console.log(formatDate(new Date()));
});
Nothing about format-date.js is fetched or executed until the button is actually clicked. That’s the whole point: the cost of loading and parsing that module is deferred to the moment it’s needed, rather than paid unconditionally on every page load.
Why this matters for performance
A bundler like Vite or webpack treats every dynamic import() call as a natural split point. Instead of producing one large JavaScript bundle, it emits a separate chunk for each dynamically imported module, and the browser fetches that chunk only when the import() call runs. This is how large applications keep their initial bundle small even as total code size grows — a settings page, an admin panel, or a rich-text editor doesn’t need to be in the bundle a first-time visitor downloads if they never open it.
This pairs naturally with the same instinct behind lazy-loading images: defer the cost of something until it’s actually visible or needed, rather than upfront. It also complements tree shaking — tree shaking removes code that’s never referenced at all, while code-splitting via dynamic import() defers code that is referenced but not needed yet.
Common patterns
Route-based splitting. Most frontend frameworks generate one chunk per route or page, loaded when the router navigates there:
const routes = {
"/dashboard": () => import("./pages/dashboard.js"),
"/settings": () => import("./pages/settings.js"),
};
Conditional feature loading. Load a heavy dependency only for the users or code paths that need it:
if (userWantsChart) {
const { renderChart } = await import("./chart-library.js");
renderChart(data);
}
Interaction-based loading. Defer non-critical widgets — a modal, an emoji picker, a syntax highlighter — until the user does something that requires them, so they never delay the initial page render.
How it interacts with module formats
Dynamic import() is part of the ECMAScript module standard, so it works naturally in ESM code. Node.js also allows import() inside CommonJS files as an escape hatch — a CommonJS module can dynamically import() an ESM-only dependency, even though it can’t require() one, because require() is synchronous and an ESM module’s evaluation isn’t guaranteed to be. That asymmetry is one of the few remaining reasons import() shows up in an otherwise CommonJS codebase.
A note on the event loop
Because import() returns a promise, module loading is inherently asynchronous. The fetch, parse, and evaluation of the target module all happen as part of the event loop’s microtask and task handling, not synchronously in the calling function. That’s usually invisible — you just await it — but it means a dynamically imported module’s top-level code doesn’t run until control returns to the event loop, which matters if you’re reasoning about execution order in code that mixes dynamic imports with other async work.
When not to bother
Code-splitting has a cost: every extra chunk is an extra network request, and on a slow connection the latency of that request can outweigh the savings from a smaller initial bundle. For small applications, or for modules that are needed almost immediately after page load anyway, a static import is simpler and avoids a visible loading flicker. Dynamic import() earns its complexity for genuinely optional, heavy, or rarely-used code — not as a default applied to every module in a project.
The takeaway
Dynamic import() loads a module on demand and returns a promise, in contrast to static import statements that resolve eagerly before your code runs. Bundlers use each import() call as a split point, generating separate chunks that load only when needed — which is how large apps keep their initial JavaScript payload small. Reach for it at natural deferral points: routes, conditional features, and interaction-triggered widgets, not as a blanket policy for every module.
Keep reading
Takina · · 4 min read requestIdleCallback Explained
requestIdleCallback runs low-priority JavaScript when the browser is idle, without blocking rendering, input, or the main thread.
Takina · · 5 min read Finding and Fixing Memory Leaks in JavaScript
A JavaScript memory leak happens when a reference outlives its usefulness and the garbage collector can't reclaim it. Common causes and how to find them.
Takina · · 4 min read Intersection Observer API Explained
The Intersection Observer API tells you when an element enters or leaves the viewport, without scroll-event polling. How it works and where to use it.