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.

esbuild bundle-size reduction passes Source flows through define, tree-shaking, drop, pure, and minify passes into a smaller output verified by the metafile. Source graph deps + dev logs define collapse branches treeShaking drop unused exports drop + pure console, debugger minify syntax + names Smaller bundle + legalComments external: heavy deps excluded from the graph, resolved at runtime --external:react --external:react-dom keeps peer deps out of the bundle Verify every pass: --metafile=meta.json then analyzeMetafile() byte-attributed tree shows which input each saved or retained byte came from lock a size budget in CI to prevent regressions
Figure: each flag is an independent pass; the metafile attributes the resulting byte delta back to a concrete input.

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.

  1. 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
  2. Print the byte-attributed tree:

    esbuild --analyze=verbose < meta.json

    Or 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 }));
  3. 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.

  4. 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:

  • minify rewrites identifiers and removes whitespace — the largest single reduction on unminified input.
  • treeShaking: true drops unreferenced exports; it only sees ESM static bindings, so a require() 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 @license blocks to dist/out.js.LEGAL.txt, keeping them legally present but out of the shipped bundle.
  • define substitutes constants so if (__DEV__) { ... } becomes if (false) { ... } and the whole branch is eliminated.
  • external removes react/react-dom from 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 call console.log(expensiveSideEffect()), that side-effecting call vanishes with the log. Keep side effects out of log arguments.
  • define needs 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.
  • external with --format=esm leaves bare imports. The output keeps import x from "react", so the runtime must resolve it (an import map, a CDN URL, or node_modules). Externalizing without a resolution plan turns a build win into a runtime Cannot find module.
  • Tree-shaking is shallower than Rollup’s. esbuild honors sideEffects and /*#__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.