Dynamic Import Chunking

TL;DR / Dynamic import() expressions tell the bundler to create separate chunks that load asynchronously at runtime, returning a Promise that resolves to the module's exports.

How It Works

  ┌──────────────┐
  │ Source Code  │
  └──────────────┘
          │
          │        import("./mod")
          │
          │
          ↓                    ┌─────────────────┐
  ┌──────────────┐      ┌─────→│  main.chunk.js  │
  │   Bundler    │      │      └─────────────────┘
  │   Analysis   │──────┐
  └──────────────┘      │
                        │
                        │      ┌─────────────────┐
                        └─────→│  mod.chunk.js   │
                               └─────────────────┘
                               Loaded on demand

Edit diagram

The dynamic import() expression is the primary mechanism by which bundlers determine where to split code. Unlike static import declarations which are resolved before execution, import() is a function-like syntax that returns a Promise resolving to the module namespace object. This dual nature — a standard JavaScript runtime feature and a bundler hint for chunk creation — makes it the foundation of all demand-loaded code in modern web applications.

From Expression to Chunk

When a bundler encounters import('./heavy-module'), it performs several steps. First, it resolves the specifier to a specific file on disk, just like a static import. Second, it marks that module and all of its transitive dependencies as belonging to a new async chunk, separate from the chunk containing the import() call. Third, it replaces the import() call in the output with runtime code that fetches the chunk via a script tag (or fetch + eval, depending on the target environment) and resolves the Promise once the chunk has loaded and executed.

The critical insight is that the bundler does not execute your code to determine chunks. It performs static analysis on the AST. This is why import('./foo') with a string literal works perfectly — the bundler knows exactly which module to split. But import(variable) with a runtime value creates ambiguity.

Magic Comments and Chunk Control

Webpack introduced "magic comments" inside dynamic imports to give developers fine-grained control over chunk behavior. /* webpackChunkName: "my-chunk" */ assigns a human-readable name used in the output filename. /* webpackPrefetch: true */ inserts a <link rel="prefetch"> in the document head. /* webpackPreload: true */ does the same with rel="preload" for higher-priority fetching. /* webpackMode: "lazy-once" */ controls how expression-based imports (those with template literals) are chunked.

Vite and Rollup handle this differently. Rollup's output.manualChunks function lets you programmatically assign modules to named chunks. Vite inherits Rollup's behavior and adds its own chunk splitting heuristics for common dependencies.

Expression-Based Dynamic Imports

When you write import(./locales/${lang}.js), the bundler cannot resolve a single file. Instead, it creates a context — a mapping of all files matching the glob pattern — and generates a separate chunk for each match. At runtime, the correct chunk is selected based on the variable's value.

This is powerful for locale loading or theme switching, but every possible match is included in the build output. Webpack's webpackInclude and webpackExclude comments filter the matches. Vite uses import.meta.glob() as a more explicit API for the same pattern.

Chunk Loading Runtime

The bundler's runtime is responsible for turning a chunk ID into a network request and managing the lifecycle. In webpack, the runtime maintains __webpack_require__.e(), which returns a Promise for a given chunk ID. It tracks loading state to ensure a chunk requested multiple times is only fetched once. It handles errors by rejecting the Promise, which propagates to your import().catch() handler or a React error boundary wrapping a Suspense boundary.

For module federation and micro-frontend architectures, the chunk loading runtime becomes especially important. It resolves remote containers, negotiates shared module versions, and initializes remote entries — all through extensions of the same chunk loading mechanism.

Interaction with Tree Shaking

Dynamic imports and tree shaking work together but with limitations. The async chunk created by import() is tree-shaken independently — unused exports within the dynamically imported module are eliminated. However, if you write const mod = await import('./utils') and access mod.foo, the bundler may struggle to determine which exports are used when the namespace object is passed around. Destructuring directly — const { foo } = await import('./utils') — gives the bundler clearer hints.

Timing and User Experience

The moment you call import() determines when the network request fires. Calling it inside a click handler means the user waits for the download after clicking. Calling it on route change means a loading state during navigation. Calling it at the module level (during initial evaluation) means it fires immediately and the chunk loads in parallel with rendering — similar to a static import but without blocking the entry chunk.

Combining import() with requestIdleCallback, intersection observers, or router prefetching lets you control exactly when chunks load relative to user interaction, balancing initial load performance against interaction latency.

Gotchas

  • Unhandled Promise rejections — if a chunk fails to load (network error, CDN outage) and you do not catch the import() Promise, you get an unhandled rejection that is difficult to recover from. Always wrap dynamic imports in error boundaries or try/catch.
  • Chunk naming collisions — using the same webpackChunkName magic comment on different dynamic imports merges them into a single chunk, which may not be intentional and inflates the chunk size.
  • Non-deterministic variable importsimport(userInput) where the specifier comes from user data is both a security risk and a bundling failure. The bundler cannot create chunks for arbitrary paths.
  • Waterfall chains — if a dynamically imported module itself contains another import(), you get sequential network requests. Use Promise.all to parallelize or restructure the dependency graph to flatten the chain.
  • SSR compatibility — dynamic import() returns a Promise, which does not work synchronously on the server. Frameworks like Next.js and Nuxt have special handling (next/dynamic, defineAsyncComponent) to make this work in SSR contexts.