Reducing esbuild Bundle Size with Minify and Tree-Shaking
Your esbuild output is larger than it should be — dev-only logging, unused exports, and bundled dependencies are all riding along into production. This guide shrinks that output with minify, treeShaking, drop, pure, legalComments, define, and external, then proves every reduction with a --metafile diff. It sits under the esbuild API and CLI for Rapid Builds overview; for the analysis primitives reused here, that page covers the metafile and analyzeMetafile() in full.
The discipline is simple: change one knob, rebuild, diff the metafile, keep what helped. esbuild’s minifier and dead-code-elimination pass are fast enough that you can iterate flag-by-flag rather than guessing at a config.
Prerequisites & reproducible setup
# esbuild 0.25.x, Node 20+
mkdir esb-size && cd esb-size
npm init -y
npm pkg set type=module
npm install --save-dev esbuild@0.25
npm install lodash-es
You need esbuild 0.25.x and Node 20+. Tree-shaking only runs when --bundle is set, so all measurements below assume a bundled build, not a bare transform.
Diagnosis workflow
Before changing any flags, establish a baseline you can diff against.
-
Build with a metafile, minified, nothing else tuned:
# esbuild 0.25.x esbuild src/index.ts --bundle --format=esm --minify --outfile=dist/out.js --metafile=meta.json -
Print the byte-attributed tree:
esbuild --analyze=verbose < meta.jsonOr programmatically:
// esbuild 0.25.x, Node 20+ import * as esbuild from 'esbuild'; import { readFile } from 'node:fs/promises'; const meta = JSON.parse(await readFile('meta.json', 'utf8')); console.log(await esbuild.analyzeMetafile(meta, { verbose: true })); -
Read the output top-down. The largest contributors are usually a CJS dependency esbuild could not tree-shake, a peer dependency that should be
external, or a dev-only branch the analyzer kept because nothing told it the branch was dead. -
Record the baseline byte size of
dist/out.js(wc -c dist/out.js) so every later change is a measurable delta.
The complete annotated solution
This single build config applies every reduction lever. Save it as build.mjs and run node build.mjs.
// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';
import { writeFile } from 'node:fs/promises';
const result = await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
format: 'esm',
outfile: 'dist/out.js',
// 1. Minify: identifiers + syntax + whitespace in one switch.
minify: true,
// 2. Tree-shaking is on by default with bundle:true; force it explicitly
// so a future config change can't silently disable it.
treeShaking: true,
// 3. Remove all console.* and debugger statements from production output.
drop: ['console', 'debugger'],
// 4. Mark side-effect-free factory calls as removable when their result
// is unused. Use for libraries that lack /*#__PURE__*/ annotations.
pure: ['Object.freeze'],
// 5. Strip license/legal comments to a sidecar file instead of inlining.
legalComments: 'external',
// 6. Replace build-time constants so dead branches collapse and get
// eliminated by the tree-shaking pass.
define: {
'process.env.NODE_ENV': '"production"',
__DEV__: 'false',
},
// 7. Keep peer/runtime deps out of the bundle entirely.
external: ['react', 'react-dom'],
// 8. Emit the metafile so the reduction can be verified.
metafile: true,
});
await writeFile('meta.json', JSON.stringify(result.metafile));
console.log(await esbuild.analyzeMetafile(result.metafile, { verbose: true }));
What each lever does to byte count:
minifyrewrites identifiers and removes whitespace — the largest single reduction on unminified input.treeShaking: truedrops unreferenced exports; it only sees ESM static bindings, so arequire()import defeats it.drop: ['console', 'debugger']deletes the statements outright, which also lets tree-shaking remove now-unused imports that only fed those logs.pure: ['Object.freeze', ...]tells esbuild a call has no side effects, so its result can be dropped when unused — the programmatic equivalent of a/*#__PURE__*/annotation.legalComments: 'external'moves@licenseblocks todist/out.js.LEGAL.txt, keeping them legally present but out of the shipped bundle.definesubstitutes constants soif (__DEV__) { ... }becomesif (false) { ... }and the whole branch is eliminated.externalremovesreact/react-domfrom the graph; the import survives in the output but the dependency bytes do not.
Verification
Rebuild and compare the metafile analysis against the baseline. Expected analyzeMetafile output shrinks and the externalized deps disappear from the input list:
dist/out.js 41.2kb 100.0%
├ src/index.ts 12.1kb 29.4%
├ node_modules/lodash-es/... 29.1kb 70.6%
(react, react-dom no longer listed — marked external)
Lock the win so a dependency bump cannot quietly undo it. Add a CI gate on the gzipped size:
# Fail CI if the gzipped bundle exceeds 45000 bytes
SIZE=$(gzip -c dist/out.js | wc -c)
echo "gzipped: ${SIZE} bytes"
test "$SIZE" -le 45000 || { echo "bundle budget exceeded"; exit 1; }
A quick before/after on raw bytes confirms the local reduction: wc -c dist/out.js against the baseline you recorded in the diagnosis step.
Gotchas & edge cases
drop: ['console']removes the argument expressions too. If you callconsole.log(expensiveSideEffect()), that side-effecting call vanishes with the log. Keep side effects out of log arguments.defineneeds JSON-encoded values. Write'process.env.NODE_ENV': '"production"'with the inner quotes —'production'without them is treated as an identifier and breaks the build.externalwith--format=esmleaves bare imports. The output keepsimport x from "react", so the runtime must resolve it (an import map, a CDN URL, ornode_modules). Externalizing without a resolution plan turns a build win into a runtimeCannot find module.- Tree-shaking is shallower than Rollup’s. esbuild honors
sideEffectsand/*#__PURE__*/but skips some edge cases Rollup catches. For a deep treatment of the static-analysis rules, see Tree-Shaking Mechanics and Dead Code Elimination.
Related
- esbuild API and CLI for Rapid Builds — the metafile and analyzeMetafile primitives reused here.
- Tree-Shaking Mechanics and Dead Code Elimination — why an export survives and how sideEffects gates elimination.
- Using esbuild context watch mode for incremental rebuilds — run these same flags in a fast rebuild loop during development.
- Using esbuild transform API for TypeScript stripping — the single-file pass that precedes bundling.