18.8 Dynamic import(): Lazy Loading Modules
Now, let’s talk about getting lazy. And I mean that in the best possible way. Why should your user’s browser have to download, parse, and compile every single module your application might need the moment the page loads? It shouldn’t. That’s a great way to make your app feel like it’s running on a potato connected by two cans and a string.
Enter import(). This isn’t the static import statement you put at the top of your file. This is a function—a function that returns a promise. And it is the single most powerful tool you have for lazy-loading modules. It lets you say, “Hey, I might need this chunk of code later, but only fetch it and get it ready when I actually ask for it.” This is the cornerstone of code-splitting, and it will make your performance metrics sing.
How It Actually Works
The syntax looks like this: import('./path/to/module.js'). It feels a bit like a function call, but it’s actually a special syntax that just happens to use parentheses (the language designers, in their infinite wisdom, decided it shouldn’t be a real function to avoid ambiguity). When the JavaScript engine hits this line, it kicks off a request for that module. Since it returns a promise, you use .then() and .catch() or the far more elegant async/await to handle the result.
That result? It’s the module namespace object. The same exact object you’d get if you did import * as Module from './module.js' at the top of your file. All of its exports are properties on that object.
// Let's say we have a module: ./lib/ui-helpers.js
export const createModal = () => { /* ... fancy modal logic ... */ };
export const dismissModal = () => { /* ... */ };
// In our main app, we can load it on demand
button.addEventListener('click', async () => {
try {
const helpers = await import('./lib/ui-helpers.js');
const modal = helpers.createModal();
// Now use your freshly loaded module
modal.show();
} catch (error) {
console.error("Well, the modal module seems to have taken a nap.", error);
}
});
The first time that button is clicked, the browser will fetch ui-helpers.js (if it hasn’t already). Every subsequent time, it will use the already-loaded module. It’s brilliant.
Why This Beats a Script Loader
You might be thinking, “Big deal, I could dynamically create a <script> tag for this.” You could, but you’d be a masochist. import() handles the entire dependency graph for you. If ui-helpers.js itself imports lodash, import() ensures lodash is fetched and loaded first. You get the full, robust ES module system, just on your schedule instead of the browser’s. Trying to manage that dependency chain yourself with script tags is a one-way ticket to headache city.
Error Handling is Non-Negotiable
This is the part everyone forgets until it’s 2 AM and their app is broken. The network is flaky. Files get renamed. Servers have bad days. If your dynamic import fails, your promise rejects. If you don’t handle that rejection, you’ll have an unhandled promise rejection floating around, which is a great way to have your application fail silently and mysteriously.
Always, always, always wrap your dynamic imports in a try/catch (for async/await) or a .catch() (for promise chains). Your users will never know about the 0.1% of times the load fails, and you’ll get a nice error log instead of a confused support ticket.
// Bad: Living on a prayer.
const module = await import('./risky-module.js');
// Good: Living responsibly.
try {
const module = await import('./risky-module.js');
} catch (error) {
// Gracefully degrade functionality, show a message to the user, log it, etc.
console.error(`Failed to load the fancy feature: ${error.message}`);
loadBasicVersion();
}
The Power of Code Splitting
The real magic happens when you combine import() with a bundler like Webpack, Rollup, or Vite. These tools are smart. When they see import(), they recognize it as a “split point.” They’ll automatically take the imported module and all its dependencies and package them into a separate chunk file. Your main bundle gets smaller, and these separate chunks are only loaded by the browser when the import() function is actually called.
This is how massive applications stay performant. The initial load is tiny, and features are loaded on-demand as the user navigates to them. It’s not just a nice-to-have; for any non-trivial application, it’s essential.
Use Cases Beyond Clicking Buttons
While the button click is the classic example, think bigger:
- Route-Based Splitting: Load the code for a specific page or view only when the user navigates to that route (this is how frameworks like React Router and Vue Router work under the hood).
- Conditional Feature Loading: Check for a browser API like
IntersectionObserverand only load the polyfill for it if it’s missing. - Loading Below-the-Fold Content: Wait until the user scrolls near a complex chart or video player before loading its hefty code.
The import() function is your key to unlocking a faster, more efficient application. It’s the difference between shoving everything into a single overstuffed suitcase and packing a neat carry-on with a few specific items you pull out only when you need them. Be lazy. Your users will thank you for it.