Splitting your esbuild bundles into smaller chunks for lazy loading is actually about optimizing initial load times, not just about making things smaller later.
Here’s how you might see it in action. Imagine a basic React app with two components, App and HeavyComponent.
// src/App.js
import React from 'react';
import HeavyComponent from './HeavyComponent'; // This will be lazy-loaded
function App() {
const [showHeavy, setShowHeavy] = React.useState(false);
return (
<div>
<h1>My App</h1>
<button onClick={() => setShowHeavy(true)}>Load Heavy Component</button>
{showHeavy && <HeavyComponent />}
</div>
);
}
export default App;
// src/HeavyComponent.js
import React from 'react';
function HeavyComponent() {
return <div>This is the heavy component!</div>;
}
export default HeavyComponent;
And a simple index.html:
<!DOCTYPE html>
<html>
<head>
<title>Lazy Loading Example</title>
</head>
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>
Without any special esbuild configuration, esbuild would bundle App.js and HeavyComponent.js into a single bundle.js. The user downloads everything upfront, even the HeavyComponent code they might never use.
To enable lazy loading with esbuild, you’ll use dynamic import() syntax and configure esbuild to output multiple files.
Here’s an esbuild command you might use in your package.json script:
"scripts": {
"build": "esbuild src/index.js --bundle --outdir=dist --format=esm --splitting --loader:.js=jsx"
}
Let’s break down that command:
src/index.js: Your application’s entry point. (You’d typically have anindex.jsthat importsApp.js).--bundle: Tells esbuild to bundle all your code.--outdir=dist: Specifies the output directory for the generated files.--format=esm: Outputs JavaScript modules in ES Module format, which is necessary for dynamic imports and splitting.--splitting: This is the key flag. It instructs esbuild to automatically identify dynamicimport()calls and split them into separate chunks.--loader:.js=jsx: Tells esbuild to treat.jsfiles as JSX files, so you don’t need.jsxextensions for React components.
When you run this command, instead of a single bundle.js, you’ll get several files in your dist directory. One will be your main entry point (e.g., index.js), and others will be the dynamically imported modules (e.g., HeavyComponent.js and potentially some shared dependency chunks).
Your index.html would then load the main entry point:
<!DOCTYPE html>
<html>
<head>
<title>Lazy Loading Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/index.js"></script>
</body>
</html>
Notice the type="module" on the script tag. This is crucial for ES Modules and dynamic imports to work in the browser.
When the user clicks the "Load Heavy Component" button, the import('./HeavyComponent') (or however your dynamic import is structured) will trigger the browser to fetch and execute the HeavyComponent.js chunk only when needed.
The problem this solves is the initial download payload. Large applications can have hundreds of kilobytes, or even megabytes, of JavaScript. By splitting these into smaller, on-demand chunks, the browser can parse and execute the essential code for the initial view much faster, leading to a snappier user experience. The user doesn’t wait for code they might never interact with.
Internally, esbuild analyzes your code for import() statements. When it finds one, it treats that module as a candidate for a separate chunk. It then generates a manifest or a way for the main bundle to know where to find and load these other chunks. The --splitting flag orchestrates this process, ensuring that dependencies are correctly shared between chunks and that the output is structured for browser-native dynamic imports. The exact mechanism involves esbuild generating import maps or similar structures that the browser’s module loader can use.
The levers you control are primarily the dynamic import() syntax in your code and the --splitting flag in esbuild. You can also influence chunking by how you structure your application and which modules you choose to dynamically import. For instance, importing entire libraries (like a charting library) dynamically is a common pattern.
One thing that trips people up is how shared dependencies are handled. If both your main bundle and a lazy-loaded chunk import the same library (e.g., lodash), esbuild is smart enough to place lodash into its own shared chunk. This chunk is then loaded once and reused by both the main bundle and the lazy-loaded chunk, preventing redundant downloads. This is managed automatically by esbuild when --splitting is enabled; it identifies common dependencies across potential chunks and extracts them.
The next thing you’ll likely encounter is optimizing these generated chunks further, perhaps by using a plugin to analyze bundle sizes or by implementing more sophisticated code-splitting strategies based on routes or user roles.