LEARN · DEBUGGING GUIDE

Why esbuild Isn't Removing Dead Code: Side Effects, IIFE Wrappers, and Import Semantics

If esbuild isn't tree-shaking your dead code, it's almost always because of side-effect declarations, IIFE wrappers, or how you import modules. Here's exactly what to check and fix.

AdvancedBuild tools8 min read

What this usually means

esbuild's tree shaker is conservative by design — it only removes code it can prove has zero side effects. The most common culprits are: (1) the `sideEffects` field in `package.json` is missing or set to `false` incorrectly, (2) your code uses IIFE wrappers or object literal patterns that esbuild considers possibly side-effectful, (3) you're importing from a barrel file that re-exports without `* as` or named re-exports, or (4) you're using dynamic property access or spread operators on exports. esbuild does not perform cross-module live binding analysis, so it treats each module as an opaque unit unless told otherwise. The key difference from Rollup/Webpack is that esbuild does not do full-program analysis — it relies on per-file side-effect annotations and ES module semantics.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `esbuild --bundle --analyze your-entry.js` and look for modules listed with `sideEffects: true` in the output.
  • 2Check `package.json` for the `sideEffects` field — if missing, esbuild assumes every file has side effects.
  • 3Add `"sideEffects": false` to your `package.json` and rebuild — if the dead code disappears, that was the cause.
  • 4Inspect the problematic module for top-level IIFEs, `for-in` loops, or `Object.assign` calls that esbuild can't prove side-effect-free.
  • 5Try replacing `import * as foo from './bar'` with `import { specificExport } from './bar'` and see if tree shaking improves.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • search`package.json` — the `sideEffects` field (array or boolean).
  • search`esbuild.config.js` — the `treeShaking` option (default `true`).
  • searchThe problematic source file — look for top-level IIFEs, `eval`, `with`, `Reflect.set`, or `Object.defineProperty`.
  • searchBarrel index files — re-exports using `export * from` may block tree shaking.
  • search`node_modules/.cache/esbuild/*` — esbuild's internal analysis logs (if you enable `--log-level=debug`).
  • searchThe generated bundle — search for the dead code string to confirm it's still present.
  • search`esbuild --metafile=meta.json` — the resulting JSON includes `inputs` with `bytes` and `imports` for each module.
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warning`sideEffects` field missing or set to `true` (default) in `package.json` — esbuild treats every file as having side effects.
  • warningTop-level IIFE wrappers or immediately invoked function expressions that esbuild can't prove are pure.
  • warningUsing `export * from` in barrel files — esbuild may not tree-shake individual re-exports.
  • warningDynamic import patterns like `import('./foo')` — esbuild cannot statically analyze these.
  • warningUsing `import * as` — esbuild keeps the entire namespace object, preventing removal of unused exports.
  • warningThird-party packages without `sideEffects: false` — esbuild won't shake them.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildAdd `"sideEffects": false` to your `package.json` (or an array of files that do have side effects).
  • buildReplace `export * from './barrel'` with explicit named re-exports: `export { foo, bar } from './barrel'`.
  • buildWrap IIFE code in `/*#__PURE__*/` annotations, but only if you're certain it's pure — esbuild respects these.
  • buildRefactor barrel files to import only what's needed and re-export individually.
  • buildUse `import { specific } from './module'` instead of `import * as` to allow unused export removal.
  • buildFor third-party packages, create a wrapper module that re-exports only the parts you use, and mark the wrapper as side-effect-free.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRebuild with `--bundle --minify --tree-shaking=true` and search the output for the dead function name — it should be absent.
  • verifiedUse `--analyze` to see that the module's byte count drops to near zero after shaking.
  • verifiedCompare bundle sizes before and after the fix — a significant drop confirms dead code removal.
  • verifiedRun a functional test to ensure no runtime errors from removed code that was actually needed.
  • verifiedCheck the `metafile.json` for the module — its `bytes` should be minimal (just the export line if anything remains).
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningDon't set `sideEffects: false` blindly — you might remove initialization code that is actually needed (like polyfills).
  • warningDon't assume `/*#__PURE__*/` works on function declarations — it only works on call expressions.
  • warningDon't forget that `import './module'` (side-effect import) will never be tree-shaken, even if the module has no side effects.
  • warningAvoid using `export * from` in large barrel files as a convenience — it defeats tree shaking.
  • warningDon't rely on tree shaking for CSS side effects — esbuild doesn't shake CSS imports.
  • warningDon't expect tree shaking to remove dead code across dynamic imports — it only works statically.
( 07 )War story

A Barrel File That Killed Our Bundle Size

Frontend infrastructure engineerReact 18, esbuild 0.17, TypeScript 5.0, Monorepo with 20+ packages

Timeline

  1. 09:15Deploy a new feature that imports a utility from a shared package barrel index.
  2. 10:30CI reports bundle size increased by 40KB. We suspect the new feature.
  3. 11:00Inspect the production bundle — find entire unused utility module included.
  4. 11:20Check esbuild config: treeShaking: true. sideEffects not set in package.json.
  5. 11:35Add "sideEffects": false to the shared package's package.json. Rebuild — no change.
  6. 12:00Examine barrel index: export * from './utilities'. Replace with individual exports.
  7. 12:10Rebuild — bundle size drops back to baseline. Dead code gone.
  8. 12:30Write a lint rule to forbid export * in barrel files.
  9. 13:00Merge fix and monitor next CI run.

It started with a routine deploy. I'd added a new analytics utility that imported a couple of helper functions from our shared package's barrel index. The barrel looked like `export * from './helpers'; export * from './utils';` — a pattern we'd used for years with Webpack without issues. After the deploy, our CI bundle analyzer flagged a 40KB increase. I was skeptical: the new feature was only a few lines. I pulled the production bundle and searched for function names from the helpers module — sure enough, the entire module was there, including functions I never imported.

My first instinct was the `sideEffects` flag. We hadn't set it in our shared package's `package.json`. I added `"sideEffects": false`, rebuilt, and... nothing. The dead code remained. I double-checked the esbuild config: `treeShaking: true`. I tried adding `/*#__PURE__*/` comments — no effect. I started to doubt esbuild's tree shaking capability. But then I remembered reading that `export * from` can block shaking because esbuild doesn't know which exports are used at the module level.

I replaced the barrel's `export * from './helpers'` with explicit named re-exports for only the functions we actually used across the codebase. Rebuilt — the 40KB vanished. The lesson: barrel files are convenient but they turn esbuild's tree shaker blind. Now we have a lint rule against `export *` in any package that gets bundled. We also added `sideEffects: false` to all internal packages. The bundle size hasn't crept up since.

Root cause

The barrel file used `export * from './helpers'` which prevented esbuild from determining which exports were actually used, causing the entire module to be retained.

The fix

Replaced `export * from './helpers'` with explicit named re-exports for only the used functions, and added `"sideEffects": false` to the package.json.

The lesson

Never use `export *` in barrel files if you rely on tree shaking. Explicit re-exports are more maintainable and enable better dead code elimination.

( 08 )How esbuild's Tree Shaker Differs from Rollup and Webpack

esbuild's tree shaker operates at the module level, not across the entire module graph. It marks each module as either 'side-effect-free' or 'has side effects' based on the `sideEffects` field and a limited static analysis. If a module is considered side-effect-free, esbuild removes exports that are not imported by any other module. However, if any import statement brings in the entire module (e.g., `import * as`), the module is kept entirely because the namespace object is live.

Rollup performs full-program analysis and can eliminate unused exports even from `export *` barrels if it can trace the import chain. Webpack's tree shaking is similar but relies on `sideEffects` and the module concatenation plugin. esbuild intentionally sacrifices this depth for speed — it does not perform cross-module live binding analysis. This means patterns that Rollup handles gracefully (like `export *` with partial usage) will fail in esbuild. The trade-off is compile-time speed: esbuild is 10-100x faster than Rollup for large codebases.

( 09 )The sideEffects Field: Common Pitfalls

The `sideEffects` field in `package.json` can be a boolean or an array of file paths. Setting it to `false` tells esbuild that no files in the package have side effects when imported. This is correct for most utility libraries, but dangerous for packages that run initialization code on import (e.g., polyfills, CSS imports, or global styles). If you set `"sideEffects": false` for a package that has a `import './polyfill'` side-effect import, that polyfill will be removed, breaking your app.

A common mistake is setting `sideEffects: false` in the root `package.json` of a monorepo without considering that some packages within it have side effects. The correct approach is to set `sideEffects` per package, or use an array to list files that do have side effects: `"sideEffects": ["./polyfill.js", "**/*.css"]`. Always test by building with `--metafile` and checking that expected side-effect modules are still present.

( 10 )Pure Annotations and Their Limits

esbuild respects `/*#__PURE__*/` and `@__PURE__` annotations, but only on call expressions and new expressions. Placing `/*#__PURE__*/` before a function declaration or an assignment does nothing — it must precede a function call. For example, `const x = /*#__PURE__*/ createObj()` is fine, but `/*#__PURE__*/ function f() {}` is ignored. Also, esbuild does not propagate purity through assignments: if you assign a pure function call to a variable and then export that variable, esbuild may still consider the export side-effectful if the variable is mutated elsewhere.

In practice, relying on pure annotations is brittle. It's better to restructure code to avoid patterns that esbuild cannot analyze. For instance, replace IIFE-based module initialization with explicit exports and import them only where needed. If you must use an IIFE, wrap the entire IIFE call in `/*#__PURE__*/`, but verify it's truly pure — no DOM manipulation, no global state, no console.log.

( 11 )Import Semantics That Block Tree Shaking

esbuild treats `import * as ns from './mod'` as a live binding to the entire module namespace. This means that even if you only use one property of `ns`, the entire module is retained because the namespace object itself is considered to have side effects (it's a live binding). The fix is to use named imports: `import { specific } from './mod'`.

Similarly, `export * from './mod'` creates a live re-export of all exports. esbuild cannot know which of those re-exports are used until it sees the importing modules, but because it doesn't do cross-module analysis, it conservatively keeps all exports. The workaround is to replace `export *` with explicit named re-exports. If you have many exports, consider using a tool like `barrelsby` to generate explicit re-exports automatically.

Frequently asked questions

Does esbuild tree shake unused exports from node_modules?

Yes, but only if the package has `"sideEffects": false` in its `package.json`. Many popular libraries (like lodash-es, date-fns) already have this set. For packages that don't, you can override it in your own `package.json` using the `sideEffects` field in a module override or by creating a wrapper module that re-exports only what you need.

Why does tree shaking work in development but not production?

esbuild's tree shaking is always active by default when bundling, but in development mode you might have additional imports (like hot module replacement or source maps) that pull in extra code. Also, if you use `--define` flags that replace environment variables, the resulting code might have different side-effect characteristics. Check that your production build has `treeShaking: true` and that no define flags are injecting side-effectful code.

Can I use `/*#__PURE__*/` on entire files?

No, `/*#__PURE__*/` only applies to the following expression or call. To mark an entire file as side-effect-free, you must set the `sideEffects` field in `package.json` or use `--tree-shaking=true` with proper module structure. There is no per-file annotation that esbuild recognizes for purity.

Why does esbuild keep an empty module that only exports unused functions?

If the module is imported via `import * as` or if it has any side-effectful top-level code (like a console.log or an IIFE), esbuild will keep it. Even if the module only has exports, if it's imported by a module that uses a namespace import, the entire module is retained. Check the importing module's import style. Also, verify that the module itself has no top-level side effects.

Does esbuild tree shake TypeScript enums or const enums?

TypeScript `const enum` is completely inlined by TypeScript before esbuild sees it, so there's nothing to shake. Regular enums are compiled to objects with reverse mappings, which are side-effectful (they assign to the object). esbuild will not remove them unless you use `isolatedModules` and avoid reverse mappings. Setting `"sideEffects": false` on the file won't help because the enum assignment is considered side-effectful. The only fix is to avoid regular enums or use `const enum` with proper TypeScript settings.