Dynamic Imports in JavaScript: Complete Guide

Dynamic imports let you load JavaScript modules on demand instead of at page load. Learn the import() syntax, lazy loading patterns, and how to split code for faster websites.

6 min read

Static imports load every module at parse time, before any code runs. JavaScript dynamic imports flip that: they load a module only when your code asks for it, returning a Promise that resolves when the module is ready.

Here is the difference in one example:

javascriptjavascript
// Static import - loads at parse time, always
import { heavyFunction } from "./heavy-module.js";
heavyFunction();
 
// Dynamic import - loads only when this line executes
const { heavyFunction } = await import("./heavy-module.js");
heavyFunction();

The static version loads heavy-module.js immediately, even if the user never triggers this code path. The dynamic version loads it only when execution reaches the import() call. For large libraries, charts, or admin-only features, this can cut initial page load time significantly.

The import() Function Syntax

import() looks like a function call but is actually a special syntax called an import expression. It takes a module specifier string and returns a Promise:

javascriptjavascript
import("./module.js").then((module) => {
  // module is the namespace object
  console.log(module);
});

The resolved module object contains all the module's exports. Here is a source module with named exports and a default export to demonstrate:

javascriptjavascript
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export default function multiply(a, b) { return a * b; }

When you dynamically import it, access named exports as properties and the default export through the .default property on the resolved module object:

javascriptjavascript
// app.js
const math = await import("./math.js");
 
console.log(math.add(2, 3));       // 5 (named export)
console.log(math.subtract(10, 4)); // 6 (named export)
console.log(math.default(3, 4));   // 12 (default export)
Dynamic import lifecycle

The browser fetches the module file over the network, parses it, executes its top-level code once, and returns the exports. If the fetch fails or the module throws during execution, the Promise rejects.

Dynamic Imports with async/await

The cleanest way to use dynamic imports is with async/await syntax. This pattern makes the code read top-to-bottom instead of chaining .then() calls.

The approach is especially important for loading heavy libraries like charting tools, which should never block the initial page load. Here is a wrapper function that safely loads Chart.js and returns it, or null if the load fails:

javascriptjavascript
async function loadChartLibrary() {
  try {
    const { Chart, registerables } = await import("chart.js");
    Chart.register(...registerables);
    return Chart;
  } catch (error) {
    console.error("Failed to load chart library:", error);
    return null;
  }
}

Now call this function only when the user triggers the feature. The library downloads on click, not on page load:

javascriptjavascript
button.addEventListener("click", async () => {
  const Chart = await loadChartLibrary();
  if (Chart) {
    new Chart(canvas, config);
  }
});

The chart library only downloads when the user clicks the button. Most users may never click it, so you do not force everyone to download Chart.js upfront.

Destructuring Named Exports from Dynamic Imports

You can destructure named exports directly from the awaited result object. This works the same as destructuring any other JavaScript object:

javascriptjavascript
const { format, parseISO, differenceInDays } = await import("date-fns");
 
console.log(format(new Date(), "yyyy-MM-dd"));

This is clean and readable. Just make sure the names match what the module actually exports.

Conditional Module Loading

Dynamic imports shine when you need different code based on runtime conditions. The function below picks the right language module based on the user's locale:

javascriptjavascript
async function getTranslationModule(userLocale) {
  if (userLocale === "es") {
    return import("./translations/es.js");
  }
  if (userLocale === "fr") {
    return import("./translations/fr.js");
  }
  return import("./translations/en.js");
}

Calling the function loads only the matching translation module. Here is how you use it with a Spanish locale setting:

javascriptjavascript
const translations = await getTranslationModule("es");
console.log(translations.greeting); // Hola

Instead of bundling every language into the main bundle, only the user's language loads. This is how production i18n systems keep bundle sizes small.

Loading on User Interaction

A practical pattern: load heavy functionality only when the user signals intent by interacting with a specific element. The search feature is a classic use case.

Most visitors never use search, so bundling a search engine library upfront wastes bandwidth for everyone. The approach below delays the import until the user focuses the search input:

javascriptjavascript
const searchInput = document.querySelector("#search");
 
searchInput.addEventListener("focus", async () => {
  const { createSearchEngine } = await import("./search-engine.js");
  const engine = createSearchEngine(searchInput);
  engine.attach();
}, { once: true });

The { once: true } option ensures the import only happens the first time the user focuses the search input. On subsequent focuses, the module is already cached by the browser.

Error Handling for Dynamic Imports

Network failures, 404 errors, and module execution errors all reject the Promise returned by import(). Always handle these failures rather than letting them crash your application.

A robust pattern wraps the import in a safety function that returns null on failure. Here is the wrapper:

javascriptjavascript
async function safeImport(path) {
  try {
    return await import(path);
  } catch (error) {
    console.warn(`Could not load ${path}:`, error.message);
    return null;
  }
}

Now call the wrapper and handle the null case gracefully so the rest of the app can continue without crashing:

javascriptjavascript
const module = await safeImport("./optional-feature.js");
if (module) {
  module.init();
} else {
  console.log("Feature unavailable, continuing without it.");
}

Without error handling, a single failed dynamic import can crash your entire application.

Common Mistake: Forgetting That import() Returns a Promise

Dynamic imports are asynchronous. This code will not work:

javascriptjavascript
// Wrong - module is a Promise, not the module object
const module = import("./math.js");
console.log(module.add(2, 3)); // TypeError: module.add is not a function
 
// Correct - await the Promise
const module = await import("./math.js");
console.log(module.add(2, 3)); // 5

The import() expression always returns a Promise. You must await it or use .then().

Dynamic Imports and Bundlers

Modern bundlers like Vite and Webpack automatically detect import() calls and split them into separate files (chunks). When you write:

javascriptjavascript
const module = await import("./heavy-module.js");

The bundler extracts heavy-module.js and its dependencies into a separate chunk. The main bundle does not include it. The chunk only downloads when the import() call executes at runtime.

This automatic splitting is the foundation of route-level code splitting and advanced code splitting strategies. You write normal dynamic imports, and the bundler handles the rest.

Rune AI

Rune AI

Key Insights

  • import() returns a Promise that resolves to a module namespace object.
  • Use dynamic imports to load code only when needed, reducing initial bundle size.
  • Always wrap dynamic imports in try/catch to handle loading failures.
  • Destructure named exports from the resolved module object.
  • Dynamic imports enable route-level code splitting in frameworks and SPAs.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between static and dynamic imports?

Static imports use the `import ... from` syntax at the top of a file and load modules at parse time, before any code runs. Dynamic imports use `import()` as a function call and load modules on demand, returning a Promise. Static imports block execution; dynamic imports do not.

Can I use dynamic imports with named exports?

Yes. The `import()` function returns a module namespace object. Access named exports as properties: `const { namedExport } = await import('./module.js')`.

Do dynamic imports work in all browsers?

Yes. Dynamic `import()` is supported in all modern browsers since 2020. It does not work in Internet Explorer, which is no longer supported by Microsoft.

Conclusion

Dynamic imports are the foundation of modern code splitting. By loading modules only when they are actually needed, you reduce the initial JavaScript bundle size and improve page load speed. Use them for large libraries, route-level components, and features that most users never access. Combine dynamic imports with bundler code splitting to build fast, scalable web applications.