esbuild 17 ⚡ Code Splitting — Dynamic imports with –splitting
esbuild’s code splitting is disabled by default. A dynamic import() in the source is inlined into the bundle unless the --splitting flag is passed, and even when it is passed, the output is only split if the format is esm and the build uses an outdir rather than an outfile . The feature exists, but it is explicitly described by esbuild’s own maintainers as a work in progress, with known caveats that affect multi-entry builds and import ordering . The flag does not make code splitting automatic; it makes it possible, and the developer is responsible for structuring the entry points and dynamic imports so that the chunks it produces are the ones the application actually needs.
The most important distinction is between inlining and splitting. Without --splitting, esbuild transforms await import('./library.js') into a Promise.resolve().then(() => require_library()) call and keeps the entire module in the main bundle . The code is lazy-evaluated, but it is not lazily loaded. With --splitting, the module is extracted into a separate chunk with a content-hashed filename, and the dynamic import becomes a network request for that chunk . The difference is visible in the output: the bundle references a separate file, and the browser downloads it only when the import executes.
Key point: --splitting only works with format=esm and an outdir. It extracts modules shared between entry points and modules referenced by dynamic imports into separate chunks. It does not support iife or cjs formats, and it does not attempt to resolve dynamic import paths that are not statically analyzable at build time.
Why --splitting requires specific conditions
The ESM-only constraint. Code splitting produces separate files that reference each other via import statements. CommonJS and IIFE outputs do not have a native mechanism for loading additional files at runtime. The ESM output format does, because import() is a native JavaScript feature. esbuild enforces this by only allowing splitting: true when format is esm . A build configured for cjs or iife cannot use splitting, and attempting to combine them produces a configuration error.
The outdir requirement. Splitting produces multiple output files. An outfile configuration specifies a single output path, which is incompatible with multiple files. The build must use outdir so that esbuild can write the entry points and the chunks into a directory with distinct filenames . This is not a limitation specific to esbuild; it is a consequence of what splitting produces.
The shared-module extraction behavior. When two entry points import the same module, esbuild extracts that module into a shared chunk, and both entry points load it via a separate network request . This reduces total transferred bytes but adds a network round-trip to the initial load of each entry point. The cascade can deepen if the shared chunk itself depends on other shared chunks. For a single entry point build, this behavior does not occur, because there is only one entry point to share with . The caveat matters for applications that build multiple entry points—a main app bundle and a separate admin bundle, for example—because a shared utility module becomes an extra request for both.
The trade-off. Splitting improves caching and lazy loading when the chunk boundaries align with usage patterns. It degrades initial load when small shared modules become separate requests that could have been inlined. esbuild does not provide a configuration option to control the chunking threshold or to mark specific imports as “inline despite being shared” . The developer who enables splitting accepts the extraction behavior as-is, or structures the entry points so that the shared modules are not shared.
a. Configuring splitting and dynamic imports
The build configuration requires three settings that work together: splitting: true, format: 'esm', and outdir. The source code uses a dynamic import() with a path that esbuild can analyze at build time.
// build.mjs — esbuild configuration with splitting
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/app.js'],
// Splitting requires a directory output.
// outfile is not compatible with multiple chunks.
outdir: 'dist',
// ESM is the only format that supports splitting.
format: 'esm',
// The flag that enables chunk extraction.
splitting: true,
bundle: true,
minify: true,
});
// src/app.js — a dynamic import
async function loadChart() {
// The path is a string literal, so esbuild can resolve it
// and place it in a separate chunk.
const { renderChart } = await import('./chart.js');
renderChart();
}
// The button triggers the import only when clicked.
document.querySelector('#chart-btn').addEventListener('click', loadChart);
The output directory contains the entry point and the extracted chunk. The entry point contains a reference to the chunk’s hashed filename, and the browser requests the chunk only when loadChart executes . The chunk’s filename includes a content hash so that a change to the chunk’s code produces a new filename and invalidates the cache.
b. The caveats that affect multi-entry builds
The splitting behavior that extracts shared modules into chunks is the source of the most common surprise. When two entry points import the same module, that module becomes a separate file, and both entry points load it over the network . This is the intended behavior for reducing duplication, but it adds a request to the initial load of each entry point.
// build.mjs — multiple entry points with a shared dependency
await esbuild.build({
entryPoints: ['src/frontend.js', 'src/backend.js'],
outdir: 'dist',
format: 'esm',
splitting: true,
bundle: true,
});
// src/frontend.js
import { formatDate } from './utils.js'; // shared with backend.js
// src/backend.js
import { formatDate } from './utils.js'; // same module
The build produces frontend.js, backend.js, and a shared chunk containing utils.js. When the browser loads frontend.js, it discovers the import of the shared chunk and makes a second request . The same happens for backend.js. The total transferred bytes are smaller than duplicating utils.js in both bundles, but the number of requests is higher. For a small utility module, the network overhead of the extra request may exceed the bytes saved. esbuild does not provide a threshold to inline small shared modules; the extraction is automatic .
The import-ordering caveat is related to this behavior. When a module is extracted into a chunk, the import statement for that chunk is hoisted to the top of the importing file. If the extracted module depends on side effects from another module that remains inlined, the order of execution can change . This is only an issue when the shared module relies on global state initialized by an inlined module, and it is not a common pattern in modern module systems, but it is a real behavioral difference that splitting introduces.
c. The dynamic import path constraint
esbuild resolves dynamic imports at build time. The path must be a string literal or a statically analyzable expression. A variable path that depends on runtime state cannot be resolved, and esbuild will leave the import() expression in the output as a runtime call rather than creating a chunk for it . The result is that the code is not split, and the import fails at runtime unless the runtime environment can resolve the path.
// ✅ Statically analyzable: esbuild creates a chunk
const module = await import('./components/Button.js');
// ❌ Runtime-dependent: esbuild cannot create a chunk
const name = getComponentName();
const module = await import(`./components/${name}.js`);
The second form is a common pattern in plugin architectures and convention-based loaders. Webpack handles it through its Context Module feature, which bundles all possible files that match the pattern and resolves the path at runtime . esbuild does not implement this behavior. The documentation states that esbuild prefers imports that are analyzable at build time, and it does not simulate a general filesystem at runtime . A project that relies on runtime-resolved dynamic imports must either enumerate the imports statically, use a plugin to transform the pattern into static imports, or accept that those imports are not split.
Complete Example Session
// ============================================
// PART 1: THE SOURCE WITH A DYNAMIC IMPORT
// ============================================
// src/app.js
async function openSettings() {
// The chunk is loaded only when the user opens settings.
const { renderSettings } = await import('./settings.js');
renderSettings();
}
document.querySelector('#settings').addEventListener('click', openSettings);
// src/settings.js
export function renderSettings() {
document.body.innerHTML = '<h1>Settings</h1>';
}
// ============================================
// PART 2: THE BUILD WITHOUT SPLITTING
// ============================================
// build.mjs — splitting disabled (default)
await esbuild.build({
entryPoints: ['src/app.js'],
outdir: 'dist',
format: 'esm',
bundle: true,
});
// Output: dist/app.js contains the entire settings.js module.
// The dynamic import is transformed into a Promise.resolve().then()
// that calls the inlined module. No network request for settings.
// ============================================
// PART 3: THE BUILD WITH SPLITTING
// ============================================
// build.mjs — splitting enabled
await esbuild.build({
entryPoints: ['src/app.js'],
outdir: 'dist',
format: 'esm',
splitting: true,
bundle: true,
});
// Output:
// dist/app.js — the entry point
// dist/settings-XXXXXX.js — the extracted chunk (hashed)
//
// app.js contains: import('./settings-XXXXXX.js')
// The browser requests the chunk only when openSettings runs.
// ============================================
// PART 4: THE FORMAT CONSTRAINT
// ============================================
// ❌ This configuration is invalid.
await esbuild.build({
entryPoints: ['src/app.js'],
outdir: 'dist',
format: 'cjs', // CommonJS does not support splitting.
splitting: true,
});
// Error: Cannot use splitting with format "cjs".
// Splitting requires format "esm".
// ============================================
// PART 5: THE OUTDIR CONSTRAINT
// ============================================
// ❌ This configuration is invalid.
await esbuild.build({
entryPoints: ['src/app.js'],
outfile: 'dist/app.js', // Single file output.
format: 'esm',
splitting: true,
});
// Error: Cannot use splitting with outfile.
// Splitting requires outdir for multiple output files.
// ============================================
// PART 6: THE SHARED MODULE EXTRACTION
// ============================================
// src/frontend.js
import { log } from './logger.js';
log('frontend');
// src/backend.js
import { log } from './logger.js';
log('backend');
// build.mjs — two entry points
await esbuild.build({
entryPoints: ['src/frontend.js', 'src/backend.js'],
outdir: 'dist',
format: 'esm',
splitting: true,
bundle: true,
});
// Output:
// dist/frontend.js — imports the shared chunk
// dist/backend.js — imports the shared chunk
// dist/logger-XXXXXX.js — the shared chunk
//
// Both entry points make a second request for logger.js.
// This is automatic; there is no threshold to inline it.
// ============================================
// PART 7: THE DYNAMIC PATH LIMITATION
// ============================================
// ✅ String literal: esbuild resolves and splits.
const mod = await import('./components/Button.js');
// ❌ Variable path: esbuild cannot resolve.
const name = 'Button';
const mod = await import(`./components/${name}.js`);
// The second form leaves the import() in the output.
// No chunk is created. The runtime must resolve the path.
// esbuild does not simulate a filesystem at runtime .
// ============================================
// PART 8: THE IMPORT ORDER CAVEAT
// ============================================
// If a shared module is extracted into a chunk,
// its import statement is hoisted to the top of the entry point.
// If the shared module depends on side effects from an inlined module,
// the execution order changes.
//
// This is rare in modern module systems but possible.
// The fix is to ensure shared modules do not depend on
// side effects from modules that remain inlined.
// ============================================
// PART 9: VERIFICATION
// ============================================
// 1. Build with splitting: esbuild --splitting --format=esm --outdir=dist
// 2. List the output directory: ls dist/
// 3. Verify the entry point and at least one chunk file exist.
// 4. Open the entry point in a browser with the network tab open.
// 5. Trigger the dynamic import.
// 6. Verify a request for the chunk file appears in the network tab.
// ============================================
// PART 10: THE DECISION CHECKLIST
// ============================================
// [ ] Is the output format esm?
// [ ] Is the output configured with outdir, not outfile?
// [ ] Are the dynamic import paths string literals?
// [ ] Are the entry points structured so shared modules are intentional?
// [ ] Is the extra network request for shared chunks acceptable?
// [ ] Are there runtime-resolved dynamic imports that need a plugin?
The ten parts covered the enabling conditions, the format and output constraints, the shared-module extraction, the dynamic path limitation, and the verification steps. The through-line is that splitting is a build-time transformation that depends on static analysis, and every limitation follows from that dependency.
Quick Reference
Splitting requirements
| Requirement | Value | Consequence if missing |
|---|---|---|
splitting | true | Dynamic imports are inlined |
format | 'esm' | Build error |
| Output | outdir | Build error |
| Import path | String literal | No chunk created |
Splitting behavior
| Scenario | Without --splitting | With --splitting |
|---|---|---|
| Single dynamic import | Inlined, lazy-evaluated | Extracted chunk, network request |
| Shared module (multi-entry) | Duplicated in each entry | Extracted chunk, shared request |
| Runtime variable path | Left as runtime call | Left as runtime call |
Format cjs/iife | N/A | Build error |
Known caveats
| Caveat | Effect |
|---|---|
| Shared modules always extracted | Extra network request, no inline threshold |
| Import order hoisting | Execution order can change |
| No runtime path resolution | Variable paths not split |
Best Practices
✅ Do This:
// Use string literals for dynamic imports
await import('./settings.js'); // ✅
// Use outdir with splitting
outdir: 'dist', splitting: true, format: 'esm' // ✅
// Structure entry points so shared modules are intentional
// Audit what is shared between entry points // ✅
// Verify chunks with the network tab
// Trigger the import and confirm the chunk loads // ✅
❌ Don’t Do This:
// Use variable paths and expect splitting
await import(`./${name}.js`); // ❌
// Use cjs or iife format with splitting
format: 'cjs', splitting: true // ❌
// Use outfile with splitting
outfile: 'dist/app.js', splitting: true // ❌
// Assume splitting inlines small shared modules
// It does not; they become chunks // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Dynamic import is inlined | splitting not enabled | Add splitting: true |
| Build error with splitting | Format is not esm | Set format: 'esm' |
| Build error with splitting | outfile used instead of outdir | Use outdir |
| No chunk for a dynamic import | Path is a runtime variable | Use a string literal or enumerate imports |
| Extra request for a small shared module | Shared modules always extracted | Accept or restructure entry points |
Real-World Examples
1. Basic splitting config
splitting: true, format: 'esm', outdir: 'dist'
2. Dynamic import with literal path
const { render } = await import('./settings.js');
3. Multi-entry shared chunk
entryPoints: ['src/a.js', 'src/b.js'], splitting: true
4. Runtime path (not split)
await import(`./components/${name}.js`); // no chunk
5. ESM format requirement
format: 'cjs', splitting: true // build error
6. Outdir requirement
outfile: 'dist/app.js', splitting: true // build error
7. Verification
ls dist/ # entry point + hashed chunk
8. Network tab check
// Trigger the import and look for the chunk request
document.querySelector('#btn').click();
9. Single entry point
// No shared chunks because there is only one entry point
entryPoints: ['src/app.js']
10. Chunk filename
// esbuild generates a content-hashed filename
// dist/settings-NAXSMFJV.js
Visual
With and Without Splitting
┌──────────────────────────────────────────────────────────────┐
│ WHAT --splitting CHANGES │
│ │
│ Without splitting With splitting │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ dist/app.js │ │ dist/app.js │ │
│ │ │ │ │ │
│ │ // entire │ │ // import('./ │ │
│ │ // settings.js │ │ // settings-XXX.js')│ │
│ │ // inlined │ │ │ │
│ │ │ │ dist/settings-XXX.js│ │
│ │ No network request │ │ // extracted chunk │ │
│ └────────────────────┘ └────────────────────┘ │
│ │
│ The code is lazy-evaluated The code is lazily loaded. │
│ in both cases. The difference The browser requests the │
│ is whether the bytes are in chunk only when the import │
│ the initial bundle. executes. │
└──────────────────────────────────────────────────────────────┘
The Shared Module Extraction
┌──────────────────────────────────────────────────────────────┐
│ MULTI-ENTRY SHARED MODULE │
│ │
│ frontend.js ──┐ │
│ ├──▶ utils.js (shared) │
│ backend.js ───┘ │
│ │
│ With splitting: │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ dist/frontend.js │ │ dist/backend.js │ │
│ │ import './utils-XXX│ │ import './utils-XXX│ │
│ └─────────┬──────────┘ └─────────┬──────────┘ │
│ │ │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ dist/utils-XXX.js │ │
│ │ Shared chunk │ │
│ └────────────────────┘ │
│ │
│ Both entry points make a second request for the shared │
│ chunk. There is no threshold to inline it. │
└──────────────────────────────────────────────────────────────┘
The Dynamic Path Limitation
┌──────────────────────────────────────────────────────────────┐
│ STRING LITERAL vs VARIABLE PATH │
│ │
│ import('./settings.js') │
│ ┌────────────────────┐ │
│ │ esbuild resolves │ │
│ │ at build time │ │
│ │ → chunk created │ │
│ └────────────────────┘ │
│ │
│ import(`./${name}.js`) │
│ ┌────────────────────┐ │
│ │ esbuild cannot │ │
│ │ resolve at build │ │
│ │ → no chunk │ │
│ │ → runtime call │ │
│ └────────────────────┘ │
│ │
│ esbuild does not simulate a filesystem at runtime. │
│ Enumerate the imports or use a plugin. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Flag | --splitting or splitting: true |
| Required format | esm |
| Required output | outdir (not outfile) |
| Default | Disabled |
| Shared modules | Always extracted, no threshold |
| Runtime variable paths | Not split |
| Import ordering | Can change for extracted chunks |
Key takeaways:
--splittingis opt-in and conditional. It requiresformat: 'esm'andoutdir. Without those conditions, the build fails or the flag has no effect. Without the flag, dynamic imports are inlined and lazy-evaluated but not lazily loaded .- Shared modules always become chunks. When multiple entry points import the same module, esbuild extracts it into a separate file. Both entry points make a network request for it. There is no configuration to inline small shared modules .
- Dynamic import paths must be statically analyzable. A string literal is resolved at build time and produces a chunk. A variable path is left as a runtime call and produces no chunk. esbuild does not simulate a filesystem at runtime .
- The feature is explicitly a work in progress. esbuild’s maintainers describe splitting as basic and note known caveats around shared modules and import ordering. The flag covers common cases, but it is not a drop-in replacement for Webpack’s more granular chunking controls .
- Splitting is a build-time decision with runtime consequences. The chunks it produces determine the network requests the browser makes. The benefit is lazy loading and caching; the cost is additional requests. The decision should be made based on the application’s entry point structure and the size of the dynamically imported modules, not because the flag exists.
Remember: Code splitting in esbuild is not a configuration detail that can be enabled and forgotten. It is a build-time transformation that depends on static analysis, and every limitation follows from that dependency. The dynamic import path must be resolvable at build time. The output format must support native imports. The output must be a directory. When those conditions are met, the chunks esbuild produces are correct and load lazily. When they are not met, the import is inlined or left as a runtime call, and the bundle is larger than expected. The flag makes splitting possible; the source code and the build configuration make it work.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!