What are dynamic imports and what do they enable
import() is a function-like expression that loads a module on demand and returns a Promise. It enables code splitting, lazy loading, conditional/feature-flagged loading, and reduced initial bundle size — the foundation of React.lazy and route-based splitting.
Dynamic import() turns module loading from a build-time, eager operation into a runtime, on-demand one.
Static vs dynamic
// Static — hoisted, loaded eagerly, must be top-level
import { heavyThing } from "./heavy";
// Dynamic — runs anywhere, returns a Promise, loads on demand
const { heavyThing } = await import("./heavy");import() looks like a function but is a special syntactic form. It returns a Promise that resolves to the module's namespace object.
What it enables
1. Code splitting — the bundler (webpack/Vite/Rollup) sees import() and splits that module into a separate chunk that's only downloaded when the import() actually runs. Your initial bundle shrinks.
2. Lazy loading — load code only when it's needed:
button.addEventListener("click", async () => {
const { openEditor } = await import("./editor"); // only when clicked
openEditor();
});3. Route-based splitting — each route's code loads when you navigate there. This is what React.lazy is built on:
const Dashboard = React.lazy(() => import("./Dashboard"));4. Conditional loading — load a module only if a condition/feature flag is true, or pick between implementations:
if (user.locale !== "en") {
await import(`./locales/${user.locale}.js`);
}5. Loading optional/heavy dependencies — charting libs, rich text editors, polyfills — kept out of the critical path.
Why it matters
The initial bundle is the single biggest lever on Time to Interactive. Dynamic imports let you ship only what the first screen needs and defer the rest. The tradeoff: each lazy chunk is a network round-trip, so you need loading states (Suspense) and sometimes prefetching (<link rel="prefetch"> or webpack magic comments) to hide the latency.
The framing
"Static import is eager and build-time; import() is a Promise-returning expression that loads on demand. It's the primitive behind code splitting — the bundler carves the target into its own chunk. That enables route-based splitting, lazy-loading heavy or rarely-used features, and conditional loading. The cost is a network round-trip per chunk, so you pair it with Suspense fallbacks and prefetching."
Follow-up questions
- •How does React.lazy use dynamic import under the hood?
- •What's the downside of too many small chunks?
- •How do you prefetch a lazy chunk before the user needs it?
- •Can you use dynamic import in a Node.js CommonJS file?
Common mistakes
- •Thinking import() is a regular function — it's a syntactic form.
- •Forgetting it returns a Promise and not awaiting it.
- •Over-splitting into dozens of tiny chunks, creating waterfall requests.
- •No loading state, so the UI freezes while the chunk downloads.
Performance considerations
- •Dynamic imports shrink the initial bundle (better TTI) but add a round-trip per chunk. Balance chunk granularity, and prefetch likely-next chunks during idle time to hide latency.
Edge cases
- •A chunk fails to load (network error) — needs an error boundary / retry.
- •Fully dynamic specifier strings can defeat bundler analysis.
- •Importing a module multiple times — it's cached after the first load.
Real-world examples
- •React.lazy + Suspense for route-level code splitting.
- •Loading a rich-text editor or charting library only when the user opens that feature.