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 problem exists because esbuild’s defaults are tuned for correctness and speed, not for the smallest possible artifact. Out of the box a --bundle build concatenates every reachable module, preserves every identifier name, keeps whitespace and comments, and leaves console.log calls and if (process.env.NODE_ENV !== 'production') guards exactly as written. None of that is wrong — it is what you want during development, where readable output and a two-hundred-millisecond rebuild matter more than a few kilobytes. It only becomes a liability at the moment you ship, when those same bytes are multiplied across every visitor’s download and parse budget. The reductions here are the switch that flips esbuild from a development-shaped build into a production-shaped one.
What breaks without the fix is rarely dramatic; it is slow erosion. A logging helper that was never removed keeps a formatting library in the graph. A barrel file re-exports a hundred utilities and, because one of them is imported through require(), tree-shaking gives up and ships all hundred. A peer dependency the host application already loads gets bundled a second time. Each of these adds single-digit kilobytes, so no single commit looks guilty, and the bundle grows until an LCP regression or a bundle-analysis audit forces a reckoning. Fixing it after the fact means unwinding months of accumulated weight; measuring it continuously — which is what the CI gate at the end of this guide does — keeps the weight from accumulating at all.
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. Treat each flag as an independent experiment with a measurable byte delta rather than a config you copy wholesale from another project; a pure annotation or an external entry that helps one dependency graph can be a no-op or a runtime failure in another.
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. The reason is structural: tree-shaking is a whole-program analysis. esbuild has to see every module in the import graph, decide which exports are actually referenced, and then omit the rest. The transform API operates on a single file with no knowledge of who imports it or what it imports, so it cannot prove any export is unreachable and therefore never drops one. If you call esbuild.transform() expecting a smaller file and get the same code back with only whitespace removed, this is why — you asked for a minify pass, not a bundle, and the dead-code-elimination stage never ran.
The dependency choices here are deliberate. lodash-es is the ES-module build of lodash, which exposes each function as a separate named export with sideEffects: false in its package.json. That combination is the best case for tree-shaking: import one function and esbuild can statically prove the other several hundred are unreferenced and drop them. Swap in the CommonJS lodash package and the same import pulls in the entire library, because a require()-shaped module is a single opaque object esbuild cannot partition. Keeping a known-good ESM dependency in the setup means the measurements below reflect the tooling, not a dependency that was never shakeable in the first place.
--bundle; a transform pass does no elimination.Diagnosis workflow
Before changing any flags, establish a baseline you can diff against. The single most common mistake in bundle-size work is tuning by intuition — flipping minify and drop together, seeing the number go down, and declaring victory without knowing which flag did the work or whether one of them regressed something else. A recorded baseline turns every subsequent change into a signed delta you can attribute, and it is the difference between “the bundle got smaller” and “externalizing react-dom saved 38kb while pure saved nothing on this graph, so drop it.” Measure first; the flags come second.
-
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:
cat meta.json | esbuild --analyze=verboseOr 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.
analyzeMetafileprints each input file with the number of bytes it contributed to the output and that figure as a percentage of the whole, sorted largest first. The number is post-tree-shaking: it counts only the bytes that actually survived into the bundle, so a dependency that appears at 30% is 30% of what shipped, not 30% of what was on disk. Read the top three or four lines and stop — the tail is noise. The largest contributors are almost always one of three shapes. A CommonJS dependency esbuild could not partition shows up at its full installed size becauserequire()defeats static analysis. A peer dependency such asreact-domappears in full when it should have been markedexternaland resolved by the host at runtime. Or a dev-only branch survives because nothing told the analyzer the branch was dead, so theifguard, its body, and everything the body imports all remain. Each shape has a different fix, and the tree tells you which one you are looking at. -
Record the baseline byte size of
dist/out.js(wc -c dist/out.js) so every later change is a measurable delta. Record the gzipped size too (gzip -c dist/out.js | wc -c), because that is the number that actually crosses the wire and the number your CI budget will gate on. The two can move in different directions: minifying identifiers shrinks raw bytes a lot but gzip already compresses repeated long names well, so the gzipped win is smaller than the raw one; conversely, removing a whole dependency shrinks both roughly in proportion. Keeping both figures means a later change that trades raw size for worse compressibility cannot hide behind the raw number.
The complete annotated solution
This single build config applies every reduction lever. Save it as build.mjs and run node build.mjs. Read it as a menu, not a mandate: the value of each option depends on your graph, and a few of them (pure, external) do nothing or actively break things when applied blindly. The config exists so you can enable one field at a time, rerun, and watch the analyzeMetafile output move.
minify is the biggest single win, external the one needing a runtime resolution plan.// 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.
How minify works under the hood
minify: true is a convenience switch for three independent transforms, and it helps to know them separately because they have different risk profiles and you can enable them individually via minifyWhitespace, minifyIdentifiers, and minifySyntax. Whitespace minification strips insignificant spaces, newlines, and indentation; it is pure formatting and cannot change behaviour, so it is always safe. Identifier minification renames local bindings to short names (getUserProfile becomes a), which is where most of the raw-byte win comes from on code with descriptive names; it only renames names it can prove are local, so it never touches globals, property accesses, or anything reached by a computed key. Syntax minification is the compiler-like pass: it collapses true to !0, folds constant expressions, merges adjacent variable declarations, converts if/else chains to ternaries and &&/|| short-circuits, and removes provably unreachable code. Syntax minification is also what makes define pay off, because collapsing if (false) { ... } to nothing happens here, not in the tree-shaking pass.
The ordering matters. define substitution happens during parsing, so by the time the syntax minifier runs, __DEV__ is already the literal false and the dead branch is a static if (false). The minifier folds that to nothing, and only then does tree-shaking notice that whatever the branch imported is no longer referenced and drop it. This is why the three levers compound: define creates dead code, minifySyntax removes it, and tree-shaking cleans up the now-orphaned imports. Enable only tree-shaking without define and the branch stays, because from the analyzer’s point of view a runtime if might still execute. The pass order is fixed inside esbuild; you influence it only through which flags you turn on.
Performance considerations
Minification is not free, but on esbuild’s scale it is close. Because the minifier is written in Go and runs as part of the same single-pass architecture that does parsing and bundling, enabling minify on a typical application graph adds a small fraction to a build already measured in the low hundreds of milliseconds — nowhere near the multiplicative cost the same setting carries in a Terser-based toolchain, where minification can dominate total build time. The practical consequence is that you do not need a separate “fast dev, slow prod” split purely to avoid minification cost; the reason to keep minify off in development is readable stack traces and debuggability, not build speed.
The one setting that does cost real time is source-map generation paired with minification, because esbuild must track every original position through the identifier and syntax rewrites. If your production build feels slow, check whether sourcemap: true is on before blaming the minifier. Emit source maps in CI where you need them for error reporting, and consider sourcemap: 'external' so the map does not inflate the shipped file or its parse cost.
Verification
Rebuild and compare the metafile analysis against the baseline. Verification is not optional here — every claim about what a flag saved has to be a byte number you can point at, because esbuild will silently accept a flag that does nothing (a pure name that never appears, an external entry for a package that was already tree-shaken out) and give you no error to tell you it was wasted config. The metafile is the ground truth: if a lever did not change the byte-attributed tree, it did not help this graph and should come out of the config so the next reader does not cargo-cult it. 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.
Gate on the gzipped number, not the raw one, because gzip is what the browser downloads and it is far more stable across cosmetic changes: renaming a variable moves raw bytes but barely moves gzip. Pick the threshold slightly above your current gzipped size — enough headroom that a legitimate feature does not trip it on the first commit, tight enough that adding a whole new dependency does. The point of the gate is not to freeze the number forever but to force the size increase into the pull request that caused it, where a reviewer can decide whether the feature is worth the kilobytes, instead of discovering the growth months later with no commit to blame.
CI integration
Wire the gate into the pipeline so it runs on every pull request, not just when someone remembers to check. The script below builds, prints both figures for the log, and fails the job on the gzipped budget. Because esbuild’s build is fast, this adds seconds, not minutes, to CI:
# esbuild 0.25.x, CI size gate — run after install, on every PR
set -euo pipefail
node build.mjs
RAW=$(wc -c dist/out.js | awk "{print \$1}")
GZ=$(gzip -c dist/out.js | wc -c)
echo "raw: ${RAW} bytes | gzipped: ${GZ} bytes"
test "$GZ" -le 45000 || { echo "gzipped bundle budget (45000) exceeded"; exit 1; }
When the gate fails, the fix is the same diagnosis loop from the top of this guide: rebuild with the metafile, run analyzeMetafile, and read the top contributors to see which input grew. Do not raise the budget as a reflex — that converts a one-time alert into permanent silent growth. Raise it only after confirming the new bytes are load-bearing and cannot be externalized or split out.
Gotchas & edge cases
drop: ['console']removes the argument expressions too. The symptom is a feature that works in development and silently stops working in production. The root cause is thatdropdeletes the entire statement, arguments included:console.log(recordMetric())loses therecordMetric()call along with the log, because from esbuild’s point of view the whole expression statement is dead. The fix is to keep side effects out of log arguments — call the function on its own line and log its result, or better, do not smuggle behaviour into logging at all. Confirm by grepping the source forconsole.calls whose arguments contain function calls before enabling the flag, and by exercising the affected code path against a production build in a smoke test.defineneeds JSON-encoded values. The symptom is a build that fails with a parse error, or worse, one that succeeds but substitutes garbage. The root cause is thatdefinevalues are raw source snippets, not strings:'process.env.NODE_ENV': 'production'substitutes the identifierproduction(an undefined variable) into your code, while'"production"'with the inner quotes substitutes the string literal you meant. Numbers and booleans work bare (__DEV__: 'false'), but any string value needs an inner pair of quotes. Confirm by searching the minified output for the defined name and checking it was replaced by a literal, not a dangling identifier.externalwith--format=esmleaves bare imports. The symptom is a clean build that throwsCannot find module "react"at runtime. The root cause is thatexternalonly tells esbuild to stop bundling the dependency; theimport x from "react"statement stays in the output, and now something downstream — an import map in the HTML, a CDN URL, or a Nodenode_modulesresolution — has to satisfy it. There is no build-time error because externalizing is a valid, intentional choice; the resolution just moves to runtime. The fix is to pair everyexternalentry with a concrete resolution plan and to confirm it by loading the actual output in the target environment, not merely by watching the build succeed.- Tree-shaking is shallower than Rollup’s. The symptom is a dependency you expected to shrink arriving nearly whole. esbuild honours the
sideEffectsfield and/*#__PURE__*/annotations and handles the common cases, but its single-pass, speed-first design skips some cross-module reachability analysis that Rollup’s slower multi-pass approach catches — a re-export chain through several barrel files, or a class whose only used method could in principle be isolated. When a dependency refuses to shake, the fix is usually upstream (a missingsideEffects: false, or a CommonJS entry point) rather than a flag. For a deep treatment of the static-analysis rules that decide whether an export survives, see Tree-Shaking Mechanics and Dead Code Elimination.
When not to use this
Not every build wants the full production treatment, and applying it reflexively costs you more than it saves. Skip minify in development builds: renamed identifiers and folded syntax make stack traces and debugger stepping nearly useless, and the build-speed cost you would be paying for is negligible, so there is no upside during the edit-reload loop. Skip drop: ['console'] on any build whose logs you actually rely on — a server bundle, a CLI, or a debug variant — because the flag does not distinguish diagnostic logging from noise. Be cautious with external on a standalone application that has no host to resolve the externalized packages; externalizing only pays off when something else already loads those dependencies, and on a self-contained bundle it just relocates a build success into a runtime failure. And do not reach for pure speculatively: it is a correctness assertion that a call has no side effects, and if you annotate a function that does have side effects, esbuild will happily delete an observable behaviour when the result looks unused. Reserve it for known-pure factory calls where you have verified the claim.
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.