from route based splitting to retrying failed chunk loads
Dynamic import via import() is more than a convenient alternative to static imports: the function returns a promise, loads code only at runtime, and enables patterns such as route based code splitting, conditional loading through feature detection, and robust retry strategies when a chunk gets lost over the network.
Table of contents
- 1. What dynamic import actually does
- 2. Basic pattern: route based code splitting
- 3. Feature detection and conditional loading
- 4. Retry pattern for failed chunk loads
- 5. Preloading and prefetching strategies
- 6. Dynamic import with template strings: limits
- 7. Import attributes for JSON and CSS modules
- 8. Error handling and error boundaries
- 9. Static versus dynamic import compared
- 10. Summary
- 11. FAQ
1. What dynamic import actually does
Dynamic import, meaning calling import() as a function instead of using it as a declaration, differs fundamentally from the static import statement. Static imports get analyzed before execution and form a fixed module graph, while import() can be called at runtime anywhere in the code and returns a promise that resolves to the module namespace object. This runtime nature makes dynamic import the central building block for code splitting in modern web applications.
The key difference from static imports is not just syntactic: import() can sit inside conditionals, loops and event handlers because it behaves like an ordinary function call. A bundler like Vite or Webpack recognizes import() calls at build time and automatically produces a separate chunk for the imported module, which only gets fetched over the network when actually invoked. This exact mechanism underlies every modern code splitting setup, whether inside a framework router or in hand written loading code.
It is important to understand that dynamic import is a language feature, not a bundler feature. Even without any build tool at all, import() works natively in the browser and in Node.js, simply loading the target module via a network request or filesystem access. Bundlers merely use this native function to automatically produce separate, cacheable chunks from it, instead of packing everything into a single bundle.
2. Basic pattern: route based code splitting
The most common use case for dynamic import is route based code splitting: every route of a single page application only loads its components once the user actually navigates there. Instead of shipping the entire application in one giant initial bundle, the user gets only the code for the currently visible route on first page load, all other routes get loaded on demand via dynamic import.
This pattern noticeably reduces initial load time, especially for applications with many rarely visited areas such as admin panels or settings pages. The trade off: on the first navigation to a new route, a brief delay occurs while the chunk loads, which is why loading indicators and preloading strategies, more on that later, almost always belong alongside it in practice.
// router.js — minimal route-based code splitting without a framework
const routes = {
"/dashboard": () => import("./pages/dashboard.js"),
"/settings": () => import("./pages/settings.js"),
"/reports": () => import("./pages/reports.js"),
};
async function navigate(path) {
const loadPage = routes[path];
if (!loadPage) return renderNotFound();
showLoadingIndicator();
try {
// Dynamic import returns a promise resolving to the module namespace
const { default: PageComponent } = await loadPage();
renderPage(new PageComponent());
} finally {
hideLoadingIndicator();
}
}
window.addEventListener("popstate", () => navigate(location.pathname));
navigate(location.pathname);
Framework routers such as React Router or Vue Router already have this exact pattern built in, usually through a lazy() helper that internally does nothing but call import() and wrap the result in a structure the framework expects. Understanding how dynamic import works under the hood lets you use framework abstractions more deliberately and add your own loading functions with extra logic when needed.
3. Feature detection and conditional loading
A lesser known but very useful pattern is dynamic import combined with feature detection: code for rarely used browser APIs or polyfills only gets loaded when it is actually needed. Instead of always shipping a polyfill for ResizeObserver, you check at runtime whether the native API is present, and only load the polyfill via dynamic import in the negative case.
The same pattern works for user settings and A/B tests: an experimental feature only gets loaded via dynamic import for users in a particular test group, all other users never receive the extra code at all. That reduces bundle size for the majority of users and also keeps experimental code cleanly separated from the core application path.
// Conditional polyfill loading via dynamic import
async function ensureResizeObserver() {
if (typeof ResizeObserver !== "undefined") return;
// Only fetched over the network for browsers that actually lack it
const { default: ResizeObserverPolyfill } = await import(
"resize-observer-polyfill"
);
window.ResizeObserver = ResizeObserverPolyfill;
}
// Feature flag driven experimental module loading
async function loadCheckoutFlow(userSegment) {
if (userSegment === "experiment-b") {
const { CheckoutFlowV2 } = await import("./checkout/flow-v2.js");
return new CheckoutFlowV2();
}
const { CheckoutFlowV1 } = await import("./checkout/flow-v1.js");
return new CheckoutFlowV1();
}
4. Retry pattern for failed chunk loads
A problem that shows up regularly in production: a chunk loaded via dynamic import no longer exists because a new deployment happened in the meantime and the old chunk files were removed from the server or CDN. Users who have had an application open for a while and then navigate to a not yet loaded route get a network error instead of the expected page, a problem that leads to a poor user experience without retry logic.
The robust dynamic import retry pattern combines a retry attempt with exponential backoff and, if all attempts fail, a forced page reload that automatically brings the user to the newest deployed version. This pattern is necessary in virtually every production ready single page application, but is surprisingly often forgotten until the first user bug report arrives.
// Retry helper for dynamic import with exponential backoff
async function importWithRetry(importFn, retries = 3, delayMs = 500) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await importFn();
} catch (err) {
const isLastAttempt = attempt === retries - 1;
if (isLastAttempt) {
// Chunk likely stale after a new deployment — force a fresh reload
console.error("Chunk load failed after retries, reloading page", err);
window.location.reload();
throw err;
}
await new Promise((resolve) => setTimeout(resolve, delayMs * 2 ** attempt));
}
}
}
// Usage in a router
const dashboardModule = await importWithRetry(() => import("./pages/dashboard.js"));
Some bundlers already offer built in solutions for this, for example Vite with the vite:preloadError event, which fires precisely in this case and can be caught centrally, instead of duplicating retry logic at every single dynamic import call site. Anyone who sets up such central error handling does not need to reimplement the retry pattern in every component.
5. Preloading and prefetching strategies
To reduce the delay on the first invocation of a chunk loaded via dynamic import, preloading is worth setting up: the chunk gets loaded before the user actually triggers the corresponding action, usually triggered by a hover event on a link or by visibility in the viewport via the Intersection Observer. The trick is to start the dynamic import call early, but only use the result once actual navigation happens.
The browser caches the module internally, so a second import() call for the same URL does not trigger another network request but returns the already resolved promise. This exact property is what makes preloading effective: the early import() on hover kicks off the download, and the later import() on click simply reuses the result of the same download.
// Preload on hover, use the cached result on click
const preloadedModules = new Map();
function preloadRoute(path) {
if (preloadedModules.has(path)) return;
// Kicks off the network request early; result is memoized by the module cache
preloadedModules.set(path, import(`./pages/${path}.js`));
}
document.querySelectorAll("a[data-route]").forEach((link) => {
link.addEventListener("mouseenter", () => preloadRoute(link.dataset.route), {
once: true,
});
});
async function navigateWithPreload(path) {
const modulePromise = preloadedModules.get(path) ?? import(`./pages/${path}.js`);
const { default: PageComponent } = await modulePromise;
renderPage(new PageComponent());
}
In addition, modern bundlers support static hints such as /* webpackPrefetch: true */ or Vite equivalents directly inside the dynamic import call, instructing the browser to preload the chunk at low priority during idle time, without needing to write any preloading code yourself.
6. Dynamic import with template strings: limits
A commonly seen but problematic pattern is dynamic import with fully dynamically computed paths, such as import(getModulePath()). While Node.js and the browser fundamentally support this at runtime, bundlers cannot statically analyze such paths at build time and therefore potentially have to include every file that could possibly match, which completely undermines the goal of code splitting.
The solution is a template string with a static prefix and suffix, where only part of it is dynamic, for example import(\`./locales/${lang}.json\`). Bundlers such as Webpack and Vite can statically recognize this pattern because the directory and file extension are known, and automatically produce one chunk per possible file in the matching directory. Fully free variables as the entire import path, by contrast, remain unanalyzable to the bundler and should be avoided.
// GOOD: bundlers can statically analyze this pattern
async function loadLocale(lang) {
// Static prefix + suffix, only the middle part is dynamic
const messages = await import(`./locales/${lang}.json`);
return messages.default;
}
// PROBLEMATIC: fully dynamic path, bundlers cannot analyze it
async function loadModuleByFullPath(fullPath) {
return import(fullPath); // may include the entire codebase in the bundle
}
// Safer alternative: an explicit allowlist map
const localeLoaders = {
de: () => import("./locales/de.json"),
en: () => import("./locales/en.json"),
fr: () => import("./locales/fr.json"),
};
async function loadLocaleSafe(lang) {
const loader = localeLoaders[lang] ?? localeLoaders.en;
return (await loader()).default;
}
7. Import attributes for JSON and CSS modules
Besides JavaScript modules, dynamic import now also supports import attributes, formerly known as import assertions, which let you explicitly specify the expected module type. The syntax import(url, { with: { type: "json" } }) loads a JSON file directly as a module, without needing a separate fetch call and manual parsing, and the browser refuses to load it if the actual MIME type does not match the expected type, adding an extra layer of security.
Something similar applies to CSS modules: with { with: { type: "css" } } you can import a stylesheet as a CSSStyleSheet object that can be assigned directly to a shadow DOM or the document. This pattern considerably reduces boilerplate for web components, because style definitions no longer need to be wired up via string concatenation or a separate stylesheet link.
// Dynamic import of JSON as a module, with an explicit type attribute
async function loadConfig() {
const { default: config } = await import("./config.json", {
with: { type: "json" },
});
return config;
}
// Dynamic import of a CSS module for a Web Component
async function attachStyles(shadowRoot) {
const { default: sheet } = await import("./component.css", {
with: { type: "css" },
});
shadowRoot.adoptedStyleSheets = [sheet];
}
8. Error handling and error boundaries
Because dynamic import returns a promise, error handling can be done naturally with try/catch around an await or with .catch() on the returned promise. In React applications, lazy() loaded components are typically combined with an error boundary that catches a loading error and displays a fallback UI instead of the whole application crashing with an unhandled error.
An often overlooked aspect: network errors from dynamic import differ from genuine module errors, such as syntax errors in the loaded code. For network errors, the retry pattern from section four is the right answer, while genuine errors in the module itself are not fixed by another loading attempt, there the error needs to be logged and a sensible fallback view shown to the user, without sending the application into a retry loop.
9. Static versus dynamic import compared
The choice between static import and dynamic import is not purely a matter of style, it depends on the actual usage pattern of the module.
| Criterion | Static import | Dynamic import | Recommendation |
|---|---|---|---|
| Loading time | During module parsing, before execution | At runtime, on demand | Dynamic for rarely used code |
| Return value | Direct bindings | Promise with namespace object | await/then needed for dynamic import |
| Static analysis | Fully possible | Only with static prefix/suffix | Avoid fully free variables as the path |
| Bundle impact | Part of the main bundle | Its own, separate chunk | Dynamic for a smaller initial bundle size |
| Conditional loading | Not possible | Fully supported | Dynamic import for feature detection |
In practice, both forms complement each other: the core path of the application, needed on every page load, stays statically imported for maximum performance on first load, while rarely used routes, large libraries and conditional code get deliberately loaded on demand via dynamic import.
Mironsoft
Bundle optimization and load time tuning
Huge initial bundle instead of a fast first load?
We analyze your bundle structure, set up route based code splitting with retry and preloading logic, and measurably reduce your application's initial load time.
Bundle analysis
Identifying code that is a good fit for dynamic import
Code splitting setup
Route based splitting with a robust retry pattern across deployments
Performance monitoring
Measuring the actual effect on load time and Core Web Vitals
10. Summary
Dynamic import is the foundation of every code splitting setup in modern web applications: the function call import() returns a promise, can be used inside conditionals and event handlers, and enables route based loading, feature detection, and conditional code for A/B tests. Robust applications always pair dynamic import with a retry pattern for failed chunk loads after new deployments, otherwise unnecessary error states show up for users who have kept a tab open for a while.
Preloading on hover or visibility noticeably reduces perceived load time, while template strings with a static prefix and suffix keep dynamic import calls statically analyzable for bundlers. Import attributes additionally extend dynamic import to JSON and CSS modules, with no manual parsing at all. Applying these patterns consistently gets you smaller initial bundles without sacrificing robustness.
Dynamic import — the essentials at a glance
Core principle
import() returns a promise and loads modules at runtime, usable inside conditionals and event handlers.
Retry pattern
Exponential backoff plus a forced reload on permanent failure prevents broken chunk loads after deployments.
Preloading
An early import() call on hover or visibility takes advantage of the browser's native module caching.
Analyzability
Template strings with a static prefix and suffix stay analyzable for bundlers, fully free paths do not.