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.

A shared module becomes one chunk both entries import With splitting enabled, a utility imported by two entry points is emitted once as a shared chunk that both entry outputs import, instead of being duplicated into each bundle. Shared code emitted once, not twice entry-a.js entry-b.js chunk-shared.jsemitted once both entries import the shared chunk — no duplication
Figure: splitting deduplicates a shared import into a single chunk both entries reference.

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.

Three conditions must all hold for splitting esbuild code splitting requires format esm because splitting is ESM-only, outdir rather than outfile because multiple files are emitted, and splitting true; missing any one produces a single bundle or an error. format: 'esm'ESM-only feature outdirmultiple files splitting: trueenable it all three, or you get one bundle
Figure: the three requirements are a set — two of three still gives you a single bundle.

Diagnosis workflow

  1. Confirm the duplication. Before changing any config, establish the baseline. Build both entries without splitting and 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 both entry-a.js and entry-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.
  2. Check the format. The most common reason a splitting: true build produces a single fat bundle is that format is left at its default. esbuild’s default output format is iife when writing to a file and cjs in some API paths — neither supports splitting. With iife esbuild throws an explicit error telling you splitting requires esm; with cjs older versions were quieter about it. The fix is to set format: 'esm' explicitly and never rely on the default when splitting. Confirm by reading the top of an output file: an ESM chunk begins with import/export statements, not an (() => { wrapper.
  3. Check outfile vs outdir. outfile names one destination path and is fundamentally incompatible with emitting multiple files, so esbuild cannot honor both outfile and splitting. 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 with outdir: '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.
Duplicated shared code is the symptom Without splitting the shared function appears in both entry outputs, which is the duplication symptom; enabling ESM format, an outdir and splitting collapses those two copies into one shared chunk. no splittingshared() in both outputsduplicated bytes splitting onshared() in one chunkboth import it
Figure: the symptom is the same function in two outputs — splitting collapses it to one.

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.
The config plus a metafile to read the chunk graph The build sets the three splitting requirements and chunkNames for chunk placement, and enabling metafile lets you inspect which chunk holds the shared module and confirm both entries import it. esm + outdir + splittingemits shared chunk metafileread the chunk graph build the split, then verify it in the metafile
Figure: the metafile is how you confirm the shared chunk exists and both entries reference 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.

Shared code in one chunk, entries import it Verification confirms the shared function appears in exactly one chunk file and that neither entry inlines it but instead imports it, which the metafile outputs corroborate by showing both entries referencing the shared chunk. shared chunk: 1 copydeduplicated entries: import, not inline0 copies inline one copy in a chunk, zero inline in entries
Figure: one copy in a chunk and zero inline in the entries is the split working.

Gotchas & edge cases

Four esbuild splitting edge cases Splitting only works with ESM so a CJS target cannot use it; the output is ESM so consumers must load it as modules; many tiny entries can produce many small chunks; and dynamic import() also creates chunks which combine with entry splitting. CJS target→ no splitting, use ESM consumers need ESM→ load as modules too many tiny chunks→ fewer entries dynamic import()→ also creates chunks
Figure: splitting is strictly ESM — a CJS target cannot use it at all.
  • 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 static import/export, which CJS lacks. The fix is to emit esm and, 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 import statements, 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 throws Cannot use import statement outside a module on the first line. In Node it means an .mjs extension or "type": "module" in package.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 and import()-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’s imports rather 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.