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.

JavaScriptintermediate
12 min read

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

FeatureStatic importDynamic import()
Syntaximport { x } from "./mod.js"const mod = await import("./mod.js")
When it loadsBefore execution (parse time)At runtime (when the line executes)
Can be conditionalNo (must be top-level)Yes (inside if, event handlers, etc.)
ReturnsBindings directlyA Promise resolving to the module namespace
Tree-shakableYesDepends on bundler
Use caseAlways-needed codeOptional, heavy, or route-specific code

Basic Syntax

import() returns a Promise that resolves to the module namespace object:

javascriptjavascript
// Load a module on demand
const mathModule = await import("./math.js");
console.log(mathModule.add(2, 3));  // 5
console.log(mathModule.PI);         // 3.14159

Accessing Default Exports

The default export is available as .default:

javascriptjavascript
const module = await import("./Logger.js");
const Logger = module.default;
const log = new Logger("app");

Destructuring the Import

javascriptjavascript
const { add, multiply } = await import("./math.js");
console.log(add(2, 3)); // 5

For default and named together:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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):

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
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:

javascriptjavascript
const mod1 = await import("./math.js");
const mod2 = await import("./math.js");
console.log(mod1 === mod2); // true — same cached instance

The module body only executes once.

Integration With Bundlers

Webpack Magic Comments

javascriptjavascript
// 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:

javascriptjavascript
// 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:

javascriptjavascript
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

StrategyWhen Module LoadsBundle Impact
Static importBefore executionIn main bundle
Dynamic import (eager)At app start, but asyncSeparate chunk
Dynamic import (lazy)On user interactionSeparate chunk
Dynamic import (prefetch)During browser idleSeparate chunk
Dynamic import (preload)In parallel with mainSeparate chunk
Rune AI

Rune AI

Key Insights

  • import() returns a Promise: Always use await or .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
RunePowered by Rune AI

Frequently Asked Questions

Can I use a variable as the import path?

Partially. `import(variable)` works at runtime, but bundlers need static analysis to create chunks. Webpack supports patterns like `import(\`./locales/${lang}.js\`)` using template literals. Fully dynamic paths prevent bundler optimization.

Does dynamic import work in Node.js?

Yes. Node.js supports `import()` in both ESM and CommonJS files. In CommonJS, it is the only way to load ESM modules.

Is import() an operator or a function?

It is syntax that looks like a function call but is technically an operator. You cannot alias it (`const i = import; i("./mod.js")` is invalid) or use `.call`/`.apply` on it.

Does dynamic import support import assertions?

Yes in environments that support them: `import("./data.json", { assert: { type: "json" } })`. This is evolving; check current browser and Node.js support.

Can I dynamically import CSS or other non-JS files?

With bundlers like webpack and Vite, yes. `import("./styles.css")` triggers the appropriate loader/plugin. Native browser ESM only supports JavaScript modules.

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.