Skip to main content

How to Use Dynamic Imports in JavaScript

Static import statements are powerful but rigid. They must appear at the top level of a module, they cannot be placed inside conditions or loops, and the module path must be a literal string. Every statically imported module is loaded and executed before your code runs, regardless of whether it is actually needed.

Dynamic imports solve all of these limitations. The import() expression loads a module at runtime, returns a Promise, and can be used anywhere in your code: inside functions, conditions, loops, event handlers, and error recovery paths. This enables critical patterns like code splitting, lazy loading, conditional feature loading, and on-demand resource management that are essential for building performant modern applications.

import(): The Dynamic Import Expression

The dynamic import() looks like a function call but is actually a special syntax recognized by the JavaScript engine. It takes a module specifier (the path to the module) and returns a Promise that resolves to the module object.

Basic Syntax

// Static import: must be at the top level, always loaded
import { add, multiply } from "./math.js";

// Dynamic import: can be anywhere, loaded on demand
const mathModule = import("./math.js");

Key Differences from Static Import

// STATIC: must be top-level, literal string, synchronous resolution
import { add } from "./math.js";

// DYNAMIC: works anywhere, supports expressions, returns a Promise
const module = await import("./math.js");

// Dynamic imports can use computed paths
const moduleName = "math";
const module2 = await import(`./${moduleName}.js`);

// Dynamic imports can be conditional
if (needsMath) {
const { add } = await import("./math.js");
}

// Dynamic imports can be inside functions
function loadMath() {
return import("./math.js");
}

// Dynamic imports work in non-module scripts too
// (Regular <script> tags, not just <script type="module">)

import() Is Not a Function

Despite the syntax resembling a function call, import() is a special operator, like typeof or void. You cannot store it in a variable, call it with apply, or use it as a value:

// WRONG: import is not a function
// const myImport = import;
// myImport("./math.js");

// CORRECT: use it directly
const module = import("./math.js");

Returns a Promise of the Module

import() returns a Promise that resolves to the module namespace object. This object contains all the module's exports as properties.

Accessing Named Exports

// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// Using .then()
import("./math.js").then(module => {
console.log(module.add(2, 3)); // 5
console.log(module.multiply(4, 5)); // 20
console.log(module.PI); // 3.14159
});

The module object returned by the Promise has a property for each named export:

import("./math.js").then(module => {
console.log(module);
// Module {
// add: ƒ add(a, b),
// multiply: ƒ multiply(a, b),
// PI: 3.14159,
// Symbol(Symbol.toStringTag): "Module"
// }
});

Accessing the Default Export

If the module has a default export, it appears as the default property on the module object:

// User.js
export default class User {
constructor(name) { this.name = name; }
greet() { return `Hi, I'm ${this.name}`; }
}
import("./User.js").then(module => {
const User = module.default;
const alice = new User("Alice");
console.log(alice.greet()); // "Hi, I'm Alice"
});

Destructuring the Module Object

You can destructure the resolved module object directly:

// Named exports
import("./math.js").then(({ add, multiply, PI }) => {
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log(PI); // 3.14159
});

// Default export
import("./User.js").then(({ default: User }) => {
const alice = new User("Alice");
console.log(alice.greet());
});

// Both default and named
import("./http-client.js").then(({ default: HttpClient, HTTP_STATUS }) => {
const client = new HttpClient("https://api.example.com");
console.log(HTTP_STATUS.OK); // 200
});

Handling Errors

Since import() returns a Promise, you can handle loading failures with .catch():

import("./might-not-exist.js")
.then(module => {
console.log("Module loaded successfully");
module.doSomething();
})
.catch(error => {
console.error("Failed to load module:", error.message);
// Provide fallback behavior
});

Modules Are Still Cached

Just like static imports, dynamically imported modules are cached. Calling import("./math.js") multiple times returns the same module instance:

const module1 = await import("./math.js");
const module2 = await import("./math.js");

console.log(module1 === module2); // true (same cached module object)

The module's code runs only once, on the first import. Subsequent dynamic imports of the same path resolve instantly with the cached result.

Use Cases: Code Splitting, Conditional Loading, Lazy Loading

Dynamic imports unlock patterns that are impossible with static imports. Here are the most important real-world use cases.

Code Splitting

Code splitting breaks your application into smaller chunks that load on demand instead of all at once. This dramatically improves initial page load time.

// app.js: the main entry point
// Only the core app logic is loaded initially

document.getElementById("dashboard-btn").addEventListener("click", async () => {
// The dashboard module is loaded ONLY when the user clicks
const { renderDashboard } = await import("./pages/dashboard.js");
renderDashboard();
});

document.getElementById("settings-btn").addEventListener("click", async () => {
// Settings module is loaded ONLY when needed
const { renderSettings } = await import("./pages/settings.js");
renderSettings();
});

document.getElementById("reports-btn").addEventListener("click", async () => {
// Reports module (possibly large with charting libraries) loads on demand
const { renderReports } = await import("./pages/reports.js");
renderReports();
});

Without code splitting, all three page modules would be included in the initial bundle, even if the user never visits the dashboard, settings, or reports pages. With dynamic imports, each page module is a separate chunk that loads only when requested.

Route-Based Code Splitting

// router.js
const routes = {
"/": () => import("./pages/Home.js"),
"/about": () => import("./pages/About.js"),
"/products": () => import("./pages/Products.js"),
"/contact": () => import("./pages/Contact.js"),
"/admin": () => import("./pages/Admin.js"),
};

async function navigate(path) {
const loader = routes[path];

if (!loader) {
const { default: NotFound } = await import("./pages/NotFound.js");
NotFound.render();
return;
}

try {
const { default: Page } = await loader();
Page.render();
} catch (error) {
console.error(`Failed to load page for ${path}:`, error);
const { default: ErrorPage } = await import("./pages/Error.js");
ErrorPage.render(error);
}
}

// Handle navigation
window.addEventListener("popstate", () => {
navigate(window.location.pathname);
});

// Initial navigation
navigate(window.location.pathname);

Each page loads only when the user navigates to it. The initial bundle contains only the router logic.

Conditional Loading

Load modules based on runtime conditions: user preferences, feature flags, environment, browser capabilities, or user roles:

// Load different implementations based on the environment
async function getStorage() {
if (typeof window !== "undefined") {
const { BrowserStorage } = await import("./storage/browser.js");
return new BrowserStorage();
} else {
const { NodeStorage } = await import("./storage/node.js");
return new NodeStorage();
}
}
// Feature flags
async function initAnalytics(config) {
if (config.analyticsEnabled) {
const { Analytics } = await import("./analytics/tracker.js");
return new Analytics(config.analyticsKey);
}

// Return a no-op implementation
return {
track() {},
identify() {},
page() {}
};
}
// User role-based loading
async function loadAdminPanel(user) {
if (user.role !== "admin") {
console.warn("Admin access required");
return null;
}

// Admin module (potentially large) loads ONLY for admins
const { AdminPanel } = await import("./admin/AdminPanel.js");
return new AdminPanel(user);
}
// Browser capability detection
async function loadImageEditor() {
const supportsWebGL = !!document.createElement("canvas").getContext("webgl2");

if (supportsWebGL) {
// Load the full WebGL-powered editor
const { WebGLEditor } = await import("./editors/webgl-editor.js");
return new WebGLEditor();
} else {
// Fall back to a simpler canvas-based editor
const { CanvasEditor } = await import("./editors/canvas-editor.js");
return new CanvasEditor();
}
}

Lazy Loading Heavy Libraries

Some libraries are large and only needed for specific features. Loading them eagerly wastes bandwidth:

// Chart rendering: only when the user views charts
async function showChart(data) {
const container = document.getElementById("chart-container");
container.innerHTML = "<p>Loading chart library...</p>";

try {
// Chart.js is ~200KB: load it only when needed
const { Chart } = await import("chart.js");

container.innerHTML = '<canvas id="myChart"></canvas>';

new Chart(document.getElementById("myChart"), {
type: "bar",
data: {
labels: data.labels,
datasets: [{
label: "Sales",
data: data.values,
backgroundColor: "rgba(54, 162, 235, 0.5)"
}]
}
});
} catch (error) {
container.innerHTML = "<p>Failed to load chart. Please try again.</p>";
}
}
// PDF generation: only when user clicks "Download PDF"
document.getElementById("download-pdf").addEventListener("click", async () => {
const button = document.getElementById("download-pdf");
button.textContent = "Generating PDF...";
button.disabled = true;

try {
const { jsPDF } = await import("jspdf");
const doc = new jsPDF();
doc.text("Report Title", 10, 10);
doc.text("Generated on: " + new Date().toLocaleDateString(), 10, 20);
doc.save("report.pdf");
} catch (error) {
alert("Failed to generate PDF. Please try again.");
console.error(error);
} finally {
button.textContent = "Download PDF";
button.disabled = false;
}
});
// Markdown rendering: only when displaying markdown content
async function renderMarkdown(markdownText) {
const { marked } = await import("marked");
const { default: DOMPurify } = await import("dompurify");

const rawHTML = marked.parse(markdownText);
const safeHTML = DOMPurify.sanitize(rawHTML);

return safeHTML;
}

Internationalization (i18n) with Dynamic Imports

Load translations on demand based on the user's language:

// locales/en.js
export default {
greeting: "Hello",
farewell: "Goodbye",
welcome: "Welcome to our app"
};

// locales/es.js
export default {
greeting: "Hola",
farewell: "Adiós",
welcome: "Bienvenido a nuestra aplicación"
};

// locales/ja.js
export default {
greeting: "こんにちは",
farewell: "さようなら",
welcome: "アプリへようこそ"
};
// i18n.js
let translations = {};

export async function loadLanguage(lang) {
try {
const module = await import(`./locales/${lang}.js`);
translations = module.default;
console.log(`Loaded ${lang} translations`);
} catch (error) {
console.warn(`Language "${lang}" not available, falling back to English`);
const module = await import("./locales/en.js");
translations = module.default;
}
}

export function t(key) {
return translations[key] || key;
}

// Usage
await loadLanguage(navigator.language.split("-")[0]); // "en", "es", "ja", etc.
console.log(t("greeting")); // "Hello" or "Hola" or "こんにちは"
warning

When using computed module paths like import(`./locales/${lang}.js`), be careful about security. If lang comes from user input, an attacker could potentially load arbitrary modules. Always validate and sanitize the path:

const SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "ja"];

async function loadLanguage(lang) {
if (!SUPPORTED_LANGUAGES.includes(lang)) {
lang = "en"; // Safe fallback
}
const module = await import(`./locales/${lang}.js`);
return module.default;
}

Loading Polyfills Conditionally

Only load polyfills when the browser actually needs them:

async function loadPolyfills() {
const polyfills = [];

if (!window.IntersectionObserver) {
polyfills.push(import("./polyfills/intersection-observer.js"));
}

if (!window.ResizeObserver) {
polyfills.push(import("./polyfills/resize-observer.js"));
}

if (!Array.prototype.at) {
polyfills.push(import("./polyfills/array-at.js"));
}

if (!window.structuredClone) {
polyfills.push(import("./polyfills/structured-clone.js"));
}

if (polyfills.length > 0) {
await Promise.all(polyfills);
console.log(`Loaded ${polyfills.length} polyfill(s)`);
} else {
console.log("No polyfills needed");
}
}

// Load polyfills before starting the app
await loadPolyfills();
startApp();

Modern browsers load zero polyfills. Older browsers load only what they need. This keeps the bundle lean for the majority of users.

Dynamic Imports in async/await

Dynamic imports work beautifully with async/await, making the code read almost like synchronous static imports.

Basic Usage

async function main() {
// Reads almost like a static import
const { add, multiply } = await import("./math.js");

console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
}

main();

Default Exports with async/await

async function createUser(name) {
const { default: User } = await import("./User.js");
return new User(name);
}

// Or without destructuring:
async function createUser(name) {
const module = await import("./User.js");
const User = module.default;
return new User(name);
}

Error Handling with try/catch

async function loadFeature(featureName) {
try {
const module = await import(`./features/${featureName}.js`);
return module;
} catch (error) {
if (error.message.includes("Cannot find module") ||
error.message.includes("Failed to fetch")) {
console.error(`Feature "${featureName}" is not available`);
return null;
}
throw error; // Re-throw unexpected errors
}
}

// Usage
const chartFeature = await loadFeature("charts");
if (chartFeature) {
chartFeature.render(data);
} else {
showFallbackUI();
}

Loading with Progress Indication

async function loadModule(path, statusElement) {
statusElement.textContent = "Loading...";
statusElement.classList.add("loading");

try {
const module = await import(path);
statusElement.textContent = "Ready";
statusElement.classList.remove("loading");
statusElement.classList.add("loaded");
return module;
} catch (error) {
statusElement.textContent = "Failed to load";
statusElement.classList.remove("loading");
statusElement.classList.add("error");
throw error;
}
}

Parallel Dynamic Imports

When you need multiple modules simultaneously, load them in parallel:

// SEQUENTIAL - slower: each waits for the previous to finish
async function loadSequential() {
const math = await import("./math.js");
const strings = await import("./string-utils.js");
const dates = await import("./date-utils.js");
return { math, strings, dates };
}

// PARALLEL - faster: all three load simultaneously
async function loadParallel() {
const [math, strings, dates] = await Promise.all([
import("./math.js"),
import("./string-utils.js"),
import("./date-utils.js")
]);
return { math, strings, dates };
}

If the modules are independent (they do not depend on each other), always load them in parallel with Promise.all.

Retry Logic for Unreliable Networks

async function importWithRetry(path, maxRetries = 3, delay = 1000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await import(path);
} catch (error) {
if (attempt === maxRetries) {
throw new Error(
`Failed to load module "${path}" after ${maxRetries} attempts: ${error.message}`
);
}
console.warn(
`Attempt ${attempt}/${maxRetries} failed for "${path}". Retrying in ${delay}ms...`
);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
}
}
}

// Usage
try {
const { renderChart } = await importWithRetry("./chart-module.js");
renderChart(data);
} catch (error) {
console.error(error.message);
showOfflineFallback();
}

Top-Level await with Dynamic Imports

In ES modules, you can use await at the top level, making dynamic imports even more seamless:

// config-loader.js (ES module)
const env = process.env.NODE_ENV || "development";

// Top-level await: no wrapping function needed
const { default: config } = await import(`./config/${env}.js`);

export default config;
// app.js
import config from "./config-loader.js";
// config is already loaded and ready

console.log(config.apiUrl);
info

Top-level await is supported in ES modules (type="module" in browsers, .mjs or "type": "module" in Node.js). It is not available in regular scripts or CommonJS modules. When a module uses top-level await, any module that imports it will wait for the await to resolve before executing.

Practical Pattern: Plugin System

// plugin-manager.js
class PluginManager {
#plugins = new Map();

async load(name) {
if (this.#plugins.has(name)) {
return this.#plugins.get(name);
}

try {
const module = await import(`./plugins/${name}/index.js`);
const plugin = module.default;

// Initialize the plugin
if (typeof plugin.init === "function") {
await plugin.init();
}

this.#plugins.set(name, plugin);
console.log(`Plugin "${name}" loaded successfully`);
return plugin;
} catch (error) {
console.error(`Failed to load plugin "${name}":`, error.message);
return null;
}
}

async loadMultiple(names) {
const results = await Promise.allSettled(
names.map(name => this.load(name))
);

const loaded = [];
const failed = [];

results.forEach((result, index) => {
if (result.status === "fulfilled" && result.value) {
loaded.push(names[index]);
} else {
failed.push(names[index]);
}
});

console.log(`Loaded: ${loaded.join(", ")}`);
if (failed.length) console.warn(`Failed: ${failed.join(", ")}`);

return { loaded, failed };
}

get(name) {
return this.#plugins.get(name);
}
}

// Usage
const plugins = new PluginManager();

await plugins.loadMultiple(["analytics", "chat-widget", "dark-mode"]);

const analytics = plugins.get("analytics");
if (analytics) {
analytics.track("page_view", { page: "/home" });
}

Summary

ConceptKey Takeaway
import() syntaxLooks like a function call but is a special operator; takes a module path string
Returns a PromiseResolves to the module namespace object containing all exports
Named exportsAccessed as properties: module.add, module.PI
Default exportAccessed as module.default
Destructuringconst { add, multiply } = await import("./math.js")
Computed pathsSupports template literals and variables: import(`./locales/${lang}.js`)
Module cachingSame module path returns the same cached instance
Code splittingLoad page-specific or feature-specific code only when needed
Conditional loadingLoad different modules based on environment, features, or user roles
Lazy loadingDefer loading of heavy libraries until they are actually used
Error handlingUse .catch() or try/catch with await for failed loads
Parallel loadingUse Promise.all to load multiple independent modules simultaneously
Works everywhereCan be used in regular scripts, modules, functions, conditions, and loops

Dynamic imports are essential for building fast, efficient web applications. They let you load only the code that is actually needed, when it is needed, keeping initial page loads fast and reducing wasted bandwidth. The key principle is simple: if a user might never need a piece of code, do not load it until they do.