Dynamic Imports in JavaScript Complete Guide
A complete guide to dynamic imports in JavaScript. Covers the import() expression, lazy loading modules on demand, code splitting for performance, conditional and event-driven loading, error handling, loading default and named exports dynamically, and integration with bundlers.
Static import declarations load modules before any code runs. Dynamic import() loads modules at runtime -- on demand, conditionally, or in response to user actions. This is the foundation of code splitting and lazy loading in modern web applications.
Static vs Dynamic Import
| Feature | Static import | Dynamic import() |
|---|---|---|
| Syntax | import { x } from "./mod.js" | const mod = await import("./mod.js") |
| When it loads | Before execution (parse time) | At runtime (when the line executes) |
| Can be conditional | No (must be top-level) | Yes (inside if, event handlers, etc.) |
| Returns | Bindings directly | A Promise resolving to the module namespace |
| Tree-shakable | Yes | Depends on bundler |
| Use case | Always-needed code | Optional, heavy, or route-specific code |
Basic Syntax
import() returns a Promise that resolves to the module namespace object:
// Load a module on demand
const mathModule = await import("./math.js");
console.log(mathModule.add(2, 3)); // 5
console.log(mathModule.PI); // 3.14159Accessing Default Exports
The default export is available as .default:
const module = await import("./Logger.js");
const Logger = module.default;
const log = new Logger("app");Destructuring the Import
const { add, multiply } = await import("./math.js");
console.log(add(2, 3)); // 5For default and named together:
const { default: Logger, createLogger } = await import("./Logger.js");See JavaScript default exports complete tutorial for more on working with defaults.
Conditional Loading
Load modules only when needed:
async function loadChart(type) {
if (type === "bar") {
const { BarChart } = await import("./charts/BarChart.js");
return new BarChart();
} else if (type === "line") {
const { LineChart } = await import("./charts/LineChart.js");
return new LineChart();
}
}The unused chart module is never downloaded.
Event-Driven Loading
Load heavy modules only when a user triggers an action:
document.getElementById("export-btn").addEventListener("click", async () => {
// Load the PDF library only when the user clicks "Export"
const { generatePDF } = await import("./pdf-generator.js");
const pdf = await generatePDF(document.getElementById("report"));
pdf.download("report.pdf");
});This keeps the initial page load fast.
Route-Based Code Splitting
In single-page applications, load route components on navigation:
const routes = {
"/": () => import("./pages/Home.js"),
"/about": () => import("./pages/About.js"),
"/dashboard": () => import("./pages/Dashboard.js"),
"/settings": () => import("./pages/Settings.js"),
};
async function navigate(path) {
const loader = routes[path];
if (!loader) {
const { NotFound } = await import("./pages/NotFound.js");
return new NotFound();
}
const module = await loader();
const Page = module.default;
return new Page();
}Bundlers (webpack, Vite, Rollup) automatically split each import() target into a separate chunk.
Error Handling
Dynamic imports can fail (network error, syntax error in the module):
async function loadModule(path) {
try {
const module = await import(path);
return module;
} catch (error) {
console.error(`Failed to load module: ${path}`, error);
// Return a fallback or re-throw
return null;
}
}Retry Pattern
async function importWithRetry(path, retries = 3, delay = 1000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await import(path);
} catch (error) {
if (attempt === retries) throw error;
console.warn(`Import attempt ${attempt} failed, retrying...`);
await new Promise(r => setTimeout(r, delay));
}
}
}Loading Multiple Modules in Parallel
const [mathMod, stringMod, dateMod] = await Promise.all([
import("./math.js"),
import("./string-utils.js"),
import("./date-utils.js"),
]);Promise.all loads all three modules concurrently rather than sequentially.
Module Caching
Dynamic imports are cached just like static imports. Calling import("./math.js") twice returns the same module instance:
const mod1 = await import("./math.js");
const mod2 = await import("./math.js");
console.log(mod1 === mod2); // true — same cached instanceThe module body only executes once.
Integration With Bundlers
Webpack Magic Comments
// Name the chunk for easier debugging
const module = await import(/* webpackChunkName: "chart" */ "./Chart.js");
// Prefetch (load during idle time for future use)
const module2 = await import(/* webpackPrefetch: true */ "./HeavyModule.js");
// Preload (load in parallel with current chunk)
const module3 = await import(/* webpackPreload: true */ "./CriticalModule.js");Vite
Vite uses native ESM in development and Rollup for production builds. Dynamic import() automatically creates separate chunks:
// Vite splits this into a separate chunk in production
const { Editor } = await import("./Editor.js");Using import() Without await
Since import() returns a Promise, you can use .then() syntax:
import("./analytics.js")
.then(({ trackEvent }) => {
trackEvent("page_view", { path: location.pathname });
})
.catch(err => {
console.warn("Analytics failed to load:", err);
});This is useful in non-async contexts or when you want fire-and-forget loading.
Comparison of Loading Strategies
| Strategy | When Module Loads | Bundle Impact |
|---|---|---|
| Static import | Before execution | In main bundle |
| Dynamic import (eager) | At app start, but async | Separate chunk |
| Dynamic import (lazy) | On user interaction | Separate chunk |
| Dynamic import (prefetch) | During browser idle | Separate chunk |
| Dynamic import (preload) | In parallel with main | Separate chunk |
Rune AI
Key Insights
- import() returns a Promise: Always use
awaitor.then()to access the loaded module namespace object - Default exports are accessed via .default:
const { default: MyClass } = await import("./mod.js") - Bundlers auto-split at import() boundaries: Each dynamic import creates a separate chunk that is loaded on demand
- Module caching applies: The same module path always returns the same cached instance, even across multiple
import()calls - Error handling is essential: Network failures can prevent module loading; wrap dynamic imports in try/catch with retry logic for critical features
Frequently Asked Questions
Can I use a variable as the import path?
Does dynamic import work in Node.js?
Is import() an operator or a function?
Does dynamic import support import assertions?
Can I dynamically import CSS or other non-JS files?
Conclusion
Dynamic import() is essential for performance-sensitive applications. It enables code splitting, lazy loading, and conditional module loading -- all of which reduce initial bundle size. The key is to keep always-needed code in static imports and defer everything else with import(). For the static module system, see JavaScript ES6 modules import export guide. For named and default export patterns used with dynamic imports, see JavaScript named exports a complete tutorial.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.