Code Splitting ESM Output with esbuild
esbuild can split a build into shared chunks so a module imported by two entry points is emitted once and referenced by both — but only under specific conditions: format: 'esm', an outdir (not outfile), and splitting: true. Get one wrong and you get a single bundle or an error. This guide sets it up and reads the resulting chunk graph. It builds on the esbuild API and CLI for rapid builds; the parent overview is esbuild & Turbopack Workflows.
The problem this solves is duplication across entry points. When you ship more than one bundle from a single build — a main app entry and an admin entry, a service worker and a page script, a set of widgets each mounted independently — any module they both import is, by default, copied into every bundle that reaches it. esbuild bundles each entry point as an isolated graph, so a 40 KB date library imported by four entries lands on disk four times and, worse, executes four times in the browser: four module-scope initializations, four copies of any singleton, four separate caches. Splitting exists to collapse those shared subgraphs into standalone chunks that every dependent output imports by reference instead of inlining.
Where this sits in the pipeline matters. esbuild resolves and bundles in a single pass, and splitting is a decision made during output generation, not a separate post-process. That is why it cannot be bolted on afterward the way a minifier can: the linker has to know, while it is still tracing the module graph, that a given module is reachable from two or more entries and therefore belongs in its own chunk. Get the configuration wrong and esbuild does not warn you into a better outcome — it either throws (splitting with a non-esm format) or quietly falls back to inlining (splitting requested but outfile set), and you discover the duplicated bytes only when you weigh the output or grep for the shared symbol. The rest of this guide is about making the linker do the right thing and then proving it did.
Prerequisites & reproducible setup
# esbuild 0.25.x, Node 18+
mkdir split-demo && cd split-demo && npm init -y
npm install --save-dev esbuild@0.25.0
mkdir src
printf "export const shared = () => 'shared';\n" > src/shared.ts
printf "import { shared } from './shared'; console.log('a', shared());\n" > src/entry-a.ts
printf "import { shared } from './shared'; console.log('b', shared());\n" > src/entry-b.ts
Code splitting in esbuild requires three things together: format: 'esm' (splitting is ESM-only), outdir instead of outfile (multiple files are emitted), and splitting: true. Missing any one either errors or silently produces a single bundle.
Each requirement follows from how the feature works rather than from an arbitrary API choice. Splitting is ESM-only because the emitted chunks talk to each other with static import/export statements, and only the ESM format has a syntax for one output file to import a live binding from another; CommonJS would need require at runtime, which defeats the static analysis esbuild relies on to hoist and dedupe. outdir is mandatory because splitting by definition produces more than one file — the entry outputs plus one or more shared chunks — and outfile names a single destination, so the two options are mutually exclusive. And splitting: true is opt-in because extracting shared chunks changes the shape of your output tree and the number of network requests a consumer makes; esbuild will not restructure your artifacts without being asked. The three are a set: satisfy two and the linker has no legal way to emit a second file, so it inlines.
Diagnosis workflow
- Confirm the duplication. Before changing any config, establish the baseline. Build both entries without
splittingand grep each output for a distinctive token from the shared module — the arrow body=> 'shared', a function name, an error string. If it appears in bothentry-a.jsandentry-b.js, you have real duplication and splitting will help. If it appears in only one, the module is not actually shared and splitting will change nothing; you are looking at the wrong problem. This step also gives you a byte count to compare against later, so you can quantify what the split saved rather than assume it. - Check the format. The most common reason a
splitting: truebuild produces a single fat bundle is thatformatis left at its default. esbuild’s default output format isiifewhen writing to a file andcjsin some API paths — neither supports splitting. Withiifeesbuild throws an explicit error telling you splitting requiresesm; withcjsolder versions were quieter about it. The fix is to setformat: 'esm'explicitly and never rely on the default when splitting. Confirm by reading the top of an output file: an ESM chunk begins withimport/exportstatements, not an(() => {wrapper. - Check
outfilevsoutdir.outfilenames one destination path and is fundamentally incompatible with emitting multiple files, so esbuild cannot honor bothoutfileandsplitting. If a build script was copied from a single-bundle setup,outfile: 'dist/bundle.js'is often left in place and silently wins. Replace it withoutdir: 'dist'and let esbuild derive each output’s name from its entry point. Confirm by listing the output directory: you should see one file per entry plus at least one chunk file, not a single hand-named bundle.
Annotated solution config
// build.mjs — esbuild 0.25.x, Node 18+, code splitting.
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/entry-a.ts', 'src/entry-b.ts'],
bundle: true,
// The three requirements, together:
format: 'esm', // splitting is ESM-only
outdir: 'dist', // NOT outfile — multiple files are emitted
splitting: true, // enable shared-chunk extraction
// Optional: control chunk file names and location.
chunkNames: 'chunks/[name]-[hash]',
metafile: true, // inspect the chunk graph
});
// Read the metafile to see which chunk holds the shared module.
import { readFileSync, writeFileSync } from 'node:fs';
// (Pass metafile: true above, then write result.metafile to disk to inspect.)
// dist/chunks/shared-*.js will contain the shared() function once,
// and both entry outputs will `import` it.
How it works under the hood
esbuild decides what becomes a chunk by reachability, not by heuristics you tune. During bundling it builds one module graph per entry point, then computes, for every module, the set of entry points from which it is reachable. A module reachable from exactly one entry stays inlined in that entry’s output. A module reachable from two or more entries is hoisted into a shared chunk so it is emitted once. That is the whole rule for entry splitting — there is no minimum-size threshold, no minChunks count, no priority groups. If two of your entries both touch a one-line utility, that utility becomes its own chunk, imports and all. This is a deliberately simple model compared with webpack’s splitChunks, and it is why esbuild’s splitting is fast but not tunable: you shape the output by shaping your entry graph, not by configuring the splitter.
Chunk file names come from the chunkNames template, which defaults to [name]-[hash]. The [hash] is content-derived: esbuild hashes the chunk’s final bytes, so the hash changes if and only if the chunk’s content changes. That is what makes these outputs safe to serve with long-lived immutable cache headers — a chunk whose code did not change keeps its URL across builds and stays in browser and CDN caches. The corollary is a subtlety worth internalizing: because an entry output imports its chunks by their hashed filenames, changing a shared module rewrites both the chunk’s name and the import specifier inside every entry that references it. A one-line edit to a widely shared utility therefore invalidates the cache of every entry that imports it, directly or transitively. Keep genuinely stable code (vendored libraries) in different import paths from churny application code so their hashes move independently.
The chunks esbuild emits are ordinary ES modules with no runtime loader injected. There is no __webpack_require__ shim, no manifest, no runtime chunk that must load first. Each output is valid ESM that a browser or Node’s module loader resolves natively through the import statements esbuild wrote. This keeps the output small and inspectable, but it also means the browser’s module resolver — not an esbuild runtime — is responsible for fetching chunks in the right order, which is why the consumers-must-load-ESM constraint below is non-negotiable.
Verification
# esbuild 0.25.x — build and confirm the shared code lives in one chunk.
node build.mjs
ls dist/chunks/ # a shared-*.js chunk exists
grep -rl "=> 'shared'" dist/chunks/ # the function is here, once
grep -c "=> 'shared'" dist/entry-a.js # expect 0 — imported, not inlined
The shared function lives in exactly one chunk under dist/chunks/, and neither entry-a.js nor entry-b.js inlines it — they import it. The metafile’s outputs shows each entry importing the shared chunk, confirming the dedup.
The grep test is a fast smoke check, but it is fragile: minification can rename or reshape the token you searched for, and a false negative from a mangled string reads exactly like a passing test. The authoritative source of truth is the metafile, which records every output file, its byte size, its inputs (which source modules contributed to it), and its imports (which other outputs it references). Reading it programmatically turns “did splitting work” into an assertion you can run in CI instead of an eyeball check.
// verify-split.mjs — esbuild 0.25.x, Node 18+. Assert the split from the metafile.
import * as esbuild from 'esbuild';
const result = await esbuild.build({
entryPoints: ['src/entry-a.ts', 'src/entry-b.ts'],
bundle: true,
format: 'esm',
outdir: 'dist',
splitting: true,
metafile: true,
write: true,
});
const outputs = result.metafile.outputs;
// Find the chunk that actually contains src/shared.ts.
const sharedChunk = Object.entries(outputs).find(
([, meta]) => Object.keys(meta.inputs).some((i) => i.endsWith('shared.ts')),
);
if (!sharedChunk) throw new Error('shared.ts was not extracted into a chunk');
const [chunkPath] = sharedChunk;
// Every entry output must import that chunk, and none may inline shared.ts.
for (const [outPath, meta] of Object.entries(outputs)) {
if (!meta.entryPoint) continue; // only check entry outputs
const importsChunk = meta.imports.some((imp) => imp.path === chunkPath);
const inlinesShared = Object.keys(meta.inputs).some((i) => i.endsWith('shared.ts'));
if (!importsChunk || inlinesShared) {
throw new Error(`${outPath} did not import the shared chunk cleanly`);
}
}
console.log(`OK: shared code is in ${chunkPath}, imported by every entry`);
This script fails loudly the moment a refactor breaks the split — for example, when someone reintroduces outfile, flips the format, or restructures imports so the shared module is no longer reachable from both entries. That failure mode is the whole point: silent regressions in chunking are invisible in a passing build and only surface as bloated payloads in production.
Gotchas & edge cases
- CJS cannot split. Splitting is ESM-only, so a target that must ship CommonJS or an IIFE global cannot use it at all. The symptom is an esbuild error (
"Splitting" is only supported when "format" is set to "esm") or, if the format defaulted, a single bundle. The root cause is that inter-chunk references need staticimport/export, which CJS lacks. The fix is to emitesmand, if you genuinely need a CJS artifact for a legacy consumer, run a second non-split build for that target rather than trying to split CJS. Confirm by checking that the failing format is the only thing that changed between a working and a broken build. - Consumers must load ESM. The chunks reference each other with bare
importstatements, so whatever loads them must be an ES module context. In the browser that means<script type="module" src="dist/entry-a.js">; a plain<script>tag throwsCannot use import statement outside a moduleon the first line. In Node it means an.mjsextension or"type": "module"inpackage.json. The failure surfaces at load time, not build time, so it is easy to ship a green build that a classic-script host cannot execute. Confirm in the target runtime, not just the build log. - Over-splitting. Because esbuild extracts a chunk for any module shared by two entries with no size floor, a build with many small entries can produce a swarm of tiny chunks, each costing a separate request and its own compression overhead. On HTTP/1.1 or without server push this can be slower than the duplication it replaced. The fix is to reduce the number of entry points where separate outputs are not actually required, or accept a little duplication for trivially small utilities. Confirm by counting chunk files and their sizes in the metafile; a long trail of sub-kilobyte chunks is the warning sign.
- Dynamic imports split too. An
import()expression is also a split point: esbuild emits the dynamically imported subgraph as its own chunk so it can be fetched on demand. This is usually what you want — route-level or feature-level lazy loading — but it means the output graph combines entry-driven andimport()-driven chunks, and a module can be pulled into a shared chunk by the interaction of the two. If a chunk appears that you did not expect, trace it through the metafile’simportsrather than assuming it came from entry splitting alone.
When not to use this, and how it compares
Reach for splitting only when you actually ship multiple entry points or use import() and those graphs overlap. A single-entry app that emits one bundle has nothing to dedupe, and turning on splitting there only fragments one file into an entry output plus chunks with no byte savings and extra requests. Likewise, if your output must be a single self-contained IIFE for a <script> tag or an embeddable widget, splitting is the wrong tool — bundle without it and accept that shared code is inlined.
Compared with Rollup’s manualChunks and webpack’s splitChunks, esbuild’s model is intentionally spartan. Rollup and webpack let you group vendor code, set size thresholds, cap the number of requests, and hand-assign modules to named chunks; esbuild gives you reachability plus a filename template and nothing else. The trade-off is speed and predictability against control: esbuild will not agonize over an optimal chunking, so builds stay in the tens-of-milliseconds range, but you cannot ask it to, say, keep all of node_modules in one vendor chunk. If you need that level of control the answer is usually to restructure entry points or introduce import() boundaries so reachability produces the chunks you want, rather than looking for a configuration knob that does not exist. When the required chunking genuinely cannot be expressed through the entry graph, that is the signal to run the production build through Rollup instead and keep esbuild for development speed.
Related
- esbuild API and CLI for rapid builds — the parent cluster on the build API.
- Reducing esbuild bundle size with minify and tree-shaking — shrinking the chunks splitting produces.
- Code-splitting strategies for large applications — the general splitting theory behind this.