esbuild vs Terser for production minification
This comparison extends the tree-shaking and dead code elimination guide and the wider JavaScript bundle optimization and code splitting workstream by settling a narrow but consequential choice: when you minify production JavaScript, do you use esbuild or Terser?
Minification is where your last few kilobytes are squeezed out before the bytes hit the wire, and it is a direct lever on the initial route JS budget (target under 150KB gzipped) and on main-thread parse time (keep individual tasks under the 50ms long-task threshold). The two dominant minifiers make opposite bets. esbuild is written in Go and minifies an order of magnitude faster than anything JavaScript-based, accepting a slightly larger output. Terser is the long-standing JavaScript minifier whose multi-pass analysis squeezes out the last percent of size at a much higher time cost. This page compares them on the four axes that matter — speed, compression ratio, mangling safety, and dead-code elimination — and tells you when each wins.
The speed-versus-bytes trade in one view
Minify speed: the headline difference
Speed is the reason esbuild exists. Because it is compiled Go with a parallel architecture, it routinely minifies a large bundle 20 to 100 times faster than Terser, which runs single-threaded in Node. On a multi-megabyte input this is the difference between a minify pass measured in tens of milliseconds versus several seconds. In a watch or preview build the gap is felt every iteration; in CI it shrinks a step that often sits on the critical path.
This speed is why bundlers increasingly default to esbuild for minification. Vite uses esbuild as its default minifier; webpack ships TerserPlugin by default but lets you swap in esbuild-loader's minifier. The speed difference does not change a single byte your users download — it changes how fast you ship.
# Compare minify wall-clock on the same bundle (median of 3 runs)
time npx esbuild app.js --minify --bundle --outfile=out.esbuild.js
time npx terser app.bundle.js -c -m -o out.terser.js
# trade-off: build speed only matters if minify is on your critical path —
# if CI caches the build, optimizing this number buys your users nothing.
Compression ratio: how much smaller is Terser, really
Terser does win on raw output size, but the margin is narrower than its reputation suggests. With multi-pass compress enabled, Terser typically emits output a few percent smaller than esbuild before gzip; after gzip the difference often shrinks to roughly 1-3% because the compressor recovers much of the redundancy esbuild leaves behind. On a 150KB gzipped initial route that is single-digit kilobytes — real, but small relative to what a single unnecessary dependency or a missed tree-shaking opportunity costs you.
The practical implication: chase the dependency graph before you chase the minifier. If your bundle is bloated, switching from esbuild to Terser recovers a few percent; removing a mis-shaken lodash or moment import recovers far more.
// terser config that maximizes ratio — and the time it costs
module.exports = {
compress: { passes: 2 }, // extra passes find more shrinking opportunities
mangle: { toplevel: true },
// trade-off: passes: 2 noticeably increases minify time for ~1% extra savings;
// skip it unless you are genuinely byte-constrained on a hot path.
};
Mangling safety: where correctness bugs hide
Mangling renames identifiers to single characters to save bytes, and this is where a minifier can silently break code. Both tools mangle local scope safely by default. The danger is mangle.properties (Terser) or --mangle-props (esbuild): renaming object properties breaks any code that accesses them by string key, reflects over them, or relies on a serialized shape (e.g. a property name sent to an API). Neither tool enables property mangling by default, and you should leave it off unless you have a reserved-name allowlist and thorough tests.
Terser exposes finer-grained safety knobs — keep_classnames, keep_fnames, reserved lists, and per-option compress toggles — which matters when a framework or error-reporting tool depends on stable function names. esbuild offers --keep-names to preserve name properties but exposes fewer dials overall, trading configurability for a smaller correctness surface.
// Preserve names for code that reflects on them (DI, decorators, error stacks)
// esbuild:
// esbuild app.js --minify --keep-names
// terser:
module.exports = {
keep_classnames: true, // some DI containers resolve by class name
keep_fnames: /Component$/, // preserve React component display names
// trade-off: keeping names increases output size; only preserve the exact
// patterns your runtime reflects on, not everything, or you give back the win.
};
Dead-code elimination: single-pass speed versus multi-pass thoroughness
Both minifiers perform dead-code elimination — dropping unreachable branches, unused locals, and if (false) blocks — but their depth differs. esbuild does competent single-pass DCE that catches the common cases. Terser's multi-pass compress can iterate, so eliminations that expose further eliminations get caught on a later pass, occasionally removing code esbuild leaves behind.
Crucially, neither minifier replaces bundler-level tree-shaking. Removing an unused export across module boundaries is the bundler's job (Rollup/webpack), driven by the sideEffects field; the minifier only prunes within what the bundler already included. So the order of operations is: get tree-shaking right at the bundler, then let the minifier clean up the residue. If you are still seeing dead code in the output, the fix usually belongs upstream — see the dependency-graph techniques in tree-shaking and dead code elimination and the bundler comparison in Vite vs webpack bundle splitting performance.
// Define-replace lets BOTH minifiers DCE dead branches at build time
// esbuild: --define:process.env.NODE_ENV='"production"'
// This turns `if (process.env.NODE_ENV !== 'production')` into `if (false)`,
// which the minifier then drops entirely.
// trade-off: getting the define wrong (or forgetting it) leaves dev-only
// warning code in the production bundle, inflating bytes and parse time.
When to pick which
| Criterion | esbuild | Terser |
|---|---|---|
| Minify speed | 20-100x faster | Baseline (slowest) |
| Output size (post-gzip) | Within ~1-3% | Smallest |
| Mangling safety controls | --keep-names, fewer dials | Granular reserved/keep_* |
| Dead-code elimination | Single-pass | Multi-pass |
| Default in tooling | Vite default; webpack opt-in | webpack default |
Pick esbuild for almost every app. The build-speed win is enormous, the output is within a few percent of Terser after gzip, and the safe defaults reduce the chance of a mangling bug. This is the right default for new projects and for any team that values fast iteration — which in turn supports the profiling loop behind improving First Input Delay and INP.
Pick Terser when you are genuinely byte-constrained on a hot path (a tiny embeddable widget, an ads-budget-sensitive script) and have measured that its multi-pass output meaningfully beats esbuild for your code, or when you need its granular name-preservation controls for a framework or error-reporting tool that reflects on identifiers. For the rare bundle where the last 1-2% is contested and tested, Terser's extra passes earn their time.
The meta-point: the minifier is a small lever. Spend your effort where the kilobytes actually live — dependency selection, tree-shaking hygiene, and code-split boundaries — and let a fast, safe minifier handle the final polish.
Related
- Tree-shaking and dead code elimination — the parent guide; minification only polishes what the bundler already pruned.
- Fixing tree-shaking issues with lodash and moment — recovering far more bytes than any minifier switch.
- Vite vs webpack bundle splitting performance — how each bundler wires up its default minifier.
- JavaScript bundle optimization and code splitting — the end-to-end payload-reduction workflow.
- Optimizing First Input Delay and INP — why smaller minified bundles cut parse time and improve responsiveness.