esbuild 16 ⚡ Tree Shaking — Side-effect detection & dead code removal
esbuild’s tree shaking is not a single pass that removes unused code. It is a series of conservative decisions about whether a statement, a class, or a module can be removed without changing observable behavior. The bundler does not know what your code does; it knows what JavaScript semantics allow it to assume. A top-level function call might have side effects. A class with a computed property key might have side effects. A module without a sideEffects declaration in its package.json is treated as if every export has side effects. Each of these assumptions is safe, and each one prevents removal. The work of tree shaking is the work of making your code and your package metadata legible to those assumptions.
The esbuild documentation lists tree shaking alongside minification and source maps as a core feature , but the documentation does not enumerate the cases where removal fails. The failure cases are where the real work happens. A package that exports a single function and also performs a top-level prototype mutation will retain everything the mutation references, even if the function is never called . A class with a computed symbol key will not be eliminated because the key could be a getter with side effects . A package.json that omits the sideEffects field will cause esbuild to default to treating every file as side-effectful, which defeats tree shaking for the entire package . These are not edge cases; they are the common reasons a bundle is larger than expected.
Key point: Tree shaking removes code only when esbuild can prove that removal does not change behavior. Top-level side effects, non-literal computed keys, and missing sideEffects metadata are the three mechanisms that most often prevent that proof. The fix is not a different bundler; it is making the code and the package metadata express what is actually side-effect-free.
Why tree shaking requires side-effect detection
The conservative assumption problem. A bundler cannot know whether a function call at the top level of a module has side effects. It might mutate a global, write to a file, or throw an error. Removing it would change behavior. esbuild’s default is to keep it. This is why top-level statements are the enemy of tree shaking: they force the bundler to retain whatever they reference, and transitively whatever those references reference. A single LazyIterable.prototype[IIterable] = true at the top of a file keeps the LazyIterable class, the IIterable symbol, and every module they depend on .
The class elimination problem. esbuild will eliminate an unused class only if the class definition is provably side-effect-free. A class with a plain method is removable. A class with a computed member key is not, because the computed expression could be a getter with side effects. This applies to symbol keys ([SYM]() {}), inline symbol calls ([Symbol("x")]() {}), and even well-known symbols ([Symbol.iterator]() {}). Only a literal string key (["squint$IIterable"]() {}) allows elimination . The symbol-based class is a common pattern in libraries that implement iterable protocols, and it is exactly the pattern that esbuild cannot remove.
The package metadata problem. The sideEffects field in package.json is the author’s declaration that some or all of a package’s files can be removed if their exports are unused. esbuild reads this field. If the field is absent, esbuild defaults to treating the package as if every file has side effects, which means no tree shaking occurs for that package . The field is not optional for a library that wants to be tree-shakeable; it is the signal that makes tree shaking possible. The common pattern is "sideEffects": false for packages with no side effects at all, or "sideEffects": ["*.css"] for packages that only import CSS for its side effects .
The trade-off. Aggressive tree shaking requires the author to accurately declare side effects, and the consumer to trust that declaration. A package that declares "sideEffects": false but actually has top-level side effects will have those side effects removed, which produces a runtime bug. The declaration is a contract: the package author promises that the modules can be removed, and the bundler removes them. A false declaration is worse than no declaration, because it produces silent breakage rather than a larger bundle.
a. The sideEffects field and the module graph
The sideEffects field is the first thing to check when a package is not tree-shaking. The field tells esbuild which files in the package have side effects and which can be removed.
// package.json — a tree-shakeable library
{
"name": "my-lib",
"sideEffects": false
}
With "sideEffects": false, esbuild is allowed to remove any module in the package whose exports are unused. A module that is imported but whose exports are never referenced is dropped entirely, including its top-level code. This is safe only if the package truly has no top-level side effects—no prototype mutations, no global assignments, no CSS imports that need to run .
// package.json — a library with CSS
{
"name": "my-ui-lib",
"sideEffects": ["*.css", "*.scss"]
}
The array form declares that only files matching the glob patterns have side effects. JavaScript modules are tree-shakeable; CSS files are not, because importing a CSS file is itself the side effect. The bundler keeps the CSS imports and removes unused JavaScript . A package that imports CSS without declaring it in sideEffects will have the CSS removed if the importing module’s exports are unused, which produces a missing-styles bug.
The sideEffects field does not override the conservative assumptions inside a module. A module that is kept because it is imported by used code still has its top-level statements preserved. The field controls whether the module as a whole can be removed, not whether individual statements inside it can be removed.
b. Top-level side effects and the retention chain
Top-level statements that mutate prototypes, assign globals, or call functions are the most common reason a tree-shakeable package retains more than expected. The statement itself is small; the chain of references it keeps is not.
// core.js — a top-level prototype mutation
LazyIterable.prototype[IIterable] = true;
concat.squint$lang$variadic = (colls) => { ... };
The first statement references LazyIterable and IIterable. esbuild keeps both. LazyIterable may reference other classes, which reference other modules. The result is that importing a single function from the file retains the entire chain, even if the function never uses LazyIterable. In the squint case, removing both statements dropped the floor from 5,377 bytes to 4,509 bytes—about 868 bytes of retained code that no import path needed . The sideEffects: false declaration did not shed them, because the statements are inside a module that was kept for a used export.
The fix is to move side-effectful code into a separate module that is imported only when needed, or to eliminate the side effect entirely. A prototype mutation that is required for the library to function cannot be removed by tree shaking; it can only be moved out of the hot path.
c. Computed keys and the class elimination boundary
The class elimination rule is narrow: a class is removable if esbuild can prove the definition is side-effect-free. A computed key is the expression that breaks the proof.
// Removable
class Removable {
plainMethod() {}
}
// Not removable
const SYM = Symbol('key');
class NotRemovable {
[SYM]() {} // esbuild keeps this class
}
The reason is that the computed key expression could be a getter with side effects. esbuild cannot know whether SYM is a plain symbol or a getter that mutates state. It keeps the class to be safe . The same applies to [Symbol("x")]() {}, [Symbol.for("x")]() {}, and [Symbol.iterator]() {}. Even the @__PURE__ annotation does not help, because the annotation applies to the call expression, not to the class definition .
The workaround is to split the symbol-keyed classes into their own modules and rely on sideEffects: false to remove them when unused. The class remains non-removable within its module, but the module itself can be dropped if nothing imports it. This is the pattern that reduced the squint floor: the symbol-keyed classes are still kept if their module is imported, but the module is not imported by the hot path .
Complete Example Session
// ============================================
// PART 1: THE TREE-SHAKEABLE PACKAGE
// ============================================
// package.json
{
"name": "my-lib",
"sideEffects": false,
"exports": "./index.js"
}
// With this declaration, esbuild can remove any module
// in the package whose exports are unused.
// ============================================
// PART 2: THE CONSUMER'S BUNDLE
// ============================================
// index.js
export { used, unused } from './core.js';
// core.js
export function used() { return 'used'; }
export function unused() { return 'unused'; }
// app.js
import { used } from 'my-lib';
console.log(used());
// Bundle result: only `used` is included.
// `unused` and any module only it references are removed.
// ============================================
// PART 3: THE TOP-LEVEL SIDE EFFECT THAT PREVENTS REMOVAL
// ============================================
// core.js — with a top-level mutation
class LazyIterable {}
const IIterable = Symbol('IIterable');
LazyIterable.prototype[IIterable] = true; // side effect
export function used() { return 'used'; }
export function unused() { return 'unused'; }
// Bundle result: `LazyIterable` and `IIterable` are retained
// because the top-level statement references them.
// `used` is included. `unused` is removed, but the side-effect
// chain remains.
// ============================================
// PART 4: THE COMPUTED KEY THAT PREVENTS CLASS REMOVAL
// ============================================
// core.js — a class with a symbol key
const SYM = Symbol('key');
class NotRemovable {
[SYM]() {}
}
export function used() { return 'used'; }
// Bundle result: `NotRemovable` is retained even if unused,
// because the computed key is not a literal.
// The class definition is considered possibly side-effectful.
// ============================================
// PART 5: THE WORKAROUND — SPLIT THE SYMBOL CLASS
// ============================================
// symbol-class.js
const SYM = Symbol('key');
export class NotRemovable {
[SYM]() {}
}
// core.js — imports the class but does not use it in the hot path
export { NotRemovable } from './symbol-class.js';
export function used() { return 'used'; }
// Bundle result: if `NotRemovable` is not imported by the consumer,
// the `symbol-class.js` module is removed entirely.
// The class is still non-removable within its module,
// but the module itself is dropped.
// ============================================
// PART 6: THE CSS SIDE EFFECT DECLARATION
// ============================================
// package.json — a UI library with CSS
{
"name": "my-ui",
"sideEffects": ["*.css"]
}
// The JavaScript modules are tree-shakeable.
// The CSS imports are retained because importing CSS is a side effect.
// A consumer that imports one component only bundles that component's CSS.
// ============================================
// PART 7: THE MISSING sideEffects FIELD
// ============================================
// package.json — no sideEffects field
{
"name": "unshaken-lib"
}
// esbuild defaults to treating the package as side-effectful.
// No tree shaking occurs for this package.
// Every imported module is retained in full, even if its exports
// are never referenced .
// ============================================
// PART 8: THE GLOBAL PROPERTY ALLOWLIST
// ============================================
// esbuild maintains an internal list of globals that are
// side-effect-free to access. Accessing `Array` is free;
// accessing `scrollY` triggers a layout and is a side effect .
//
// This matters for code like:
class ExampleIterator extends Iterator {}
// `Iterator` was added to the allowlist in esbuild 0.25.12,
// so this class can now be tree-shaken if unused .
// ============================================
// PART 9: VERIFICATION
// ============================================
// 1. Build: esbuild app.js --bundle --minify --format=esm
// 2. Inspect the output: search for the name of the unused export.
// 3. If it appears, trace the chain:
// - Is there a top-level side effect referencing it?
// - Is it a class with a computed key?
// - Is the package's sideEffects field declared?
// 4. Use esbuild's metafile to see which inputs survived.
// ============================================
// PART 10: THE DECISION CHECKLIST
// ============================================
// [ ] Does the package declare sideEffects?
// [ ] Are top-level side effects isolated from the hot path?
// [ ] Are symbol-keyed classes in separate modules?
// [ ] Does the consumer's bundler resolve the ESM build?
// [ ] Is the output checked for retained unused exports?
The ten parts covered the sideEffects declaration, the top-level side-effect chain, the computed-key class boundary, the split-module workaround, the CSS declaration, and the global allowlist. The through-line is that tree shaking is a collaboration between the package author and the bundler: the author declares what is side-effect-free, and the bundler removes what the declaration permits.
Quick Reference
sideEffects field values
| Value | Meaning | Use case |
|---|---|---|
false | No files have side effects | Pure JS library |
["*.css"] | Only CSS files have side effects | UI library with styles |
| absent | All files treated as side-effectful | No tree shaking |
What esbuild cannot remove
| Pattern | Why | Fix |
|---|---|---|
| Top-level prototype mutation | References the target symbol | Isolate in a separate module |
| Class with computed symbol key | Key could be a getter | Split into its own module |
Package without sideEffects | Default is side-effectful | Declare the field |
| Module resolved to CommonJS | Not statically analyzable | Resolve the ESM build |
The global access allowlist
| Global | Side-effect-free? | Added in |
|---|---|---|
Array | Yes | Base |
Atomics | Yes | 0.25.12 |
Iterator | Yes | 0.25.12 |
scrollY | No (layout) | — |
Source:
Best Practices
✅ Do This:
// Declare sideEffects for every package
"sideEffects": false // ✅
// Use the array form for CSS
"sideEffects": ["*.css"] // ✅
// Isolate top-level side effects in a separate module
// lazy-iterable-setup.js
LazyIterable.prototype[IIterable] = true; // ✅
// Split symbol-keyed classes into their own modules
// symbol-class.js
export class SymbolClass { [SYM]() {} } // ✅
❌ Don’t Do This:
// Put top-level mutations in the hot path
LazyIterable.prototype[IIterable] = true; // ❌
// Use computed keys on classes that should be removable
class C { [SYM]() {} } // ❌
// Omit sideEffects and expect tree shaking
// package.json without the field // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Entire package is bundled | No sideEffects field | Add "sideEffects": false |
| CSS missing in consumer | CSS not declared in sideEffects | Add "*.css" to the array |
| Prototype class retained | Top-level mutation references it | Move mutation to a separate module |
| Symbol class retained | Computed key prevents elimination | Split class into its own module |
Iterator subclass retained | Global not on allowlist before 0.25.12 | Upgrade esbuild |
Real-World Examples
1. Pure JS library
"sideEffects": false
2. UI library with CSS
"sideEffects": ["*.css", "*.scss"]
3. Isolated top-level side effect
// setup.js
LazyIterable.prototype[IIterable] = true;
4. Split symbol class
// symbol-class.js
export class NotRemovable { [SYM]() {} }
5. ESM build resolution
"module": "./es/index.js"
6. esbuild bundle command
esbuild app.js --bundle --minify --format=esm
7. Metafile inspection
esbuild app.js --bundle --metafile=meta.json
8. Global allowlist
class ExampleIterator extends Iterator {} // removable since 0.25.12
9. CSS import retention
import './styles.css'; // retained if "*.css" is in sideEffects
10. Package verification
# Search the bundle for the unused export name
grep 'unusedFunction' dist/bundle.js
Visual
The Tree Shaking Decision Flow
┌──────────────────────────────────────────────────────────────┐
│ WHY A MODULE IS KEPT OR REMOVED │
│ │
│ Is the module imported by used code? │
│ │ │
│ ├── No ──▶ Is sideEffects declared? │
│ │ │ │
│ │ ├── false ──▶ REMOVED │
│ │ └── absent ──▶ KEPT (default safe) │
│ │ │
│ └── Yes ──▶ Does the module have top-level side │
│ effects? │
│ │ │
│ ├── Yes ──▶ KEPT (and chain) │
│ └── No ──▶ Does it export a │
│ symbol-keyed class? │
│ │ │
│ ├── Yes ──▶ KEPT│
│ └── No ──▶ removable│
└──────────────────────────────────────────────────────────────┘
The Top-Level Side Effect Chain
┌──────────────────────────────────────────────────────────────┐
│ ONE STATEMENT, MANY RETAINED MODULES │
│ │
│ LazyIterable.prototype[IIterable] = true; │
│ │ │ │
│ │ └──▶ IIterable symbol kept │
│ │ │
│ └──▶ LazyIterable class kept │
│ │ │
│ ├──▶ references Cons │
│ ├──▶ references List │
│ └──▶ references SortedSet │
│ │
│ The statement is one line. The retention chain is thousands │
│ of bytes. Move the statement to a module that is only │
│ imported when the protocol is needed. │
└──────────────────────────────────────────────────────────────┘
The Computed Key Boundary
┌──────────────────────────────────────────────────────────────┐
│ WHAT esbuild CAN AND CANNOT ELIMINATE │
│ │
│ class C { plainMethod() {} } │
│ └──▶ REMOVED (no computed key) │
│ │
│ class C { ["literal"]() {} } │
│ └──▶ REMOVED (literal string key) │
│ │
│ class C { [SYM]() {} } │
│ └──▶ KEPT (computed symbol key) │
│ │
│ class C { [Symbol("x")]() {} } │
│ └──▶ KEPT (inline symbol call) │
│ │
│ class C { [Symbol.iterator]() {} } │
│ └──▶ KEPT (well-known symbol) │
│ │
│ The key must be a literal string for elimination. │
│ Split the class into its own module to allow module-level │
│ removal via sideEffects: false. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
sideEffects: false | No files have side effects; all removable |
sideEffects: ["*.css"] | Only CSS files have side effects |
Absent sideEffects | All files treated as side-effectful |
| Top-level mutation | Retains the referenced chain |
| Computed symbol key | Prevents class elimination |
| Literal string key | Allows class elimination |
| Global allowlist | Iterator and Atomics added in 0.25.12 |
Key takeaways:
- The
sideEffectsfield is the first thing to check. A package without it is treated as fully side-effectful by esbuild. Declaringfalseor a glob array is the prerequisite for any tree shaking to occur . - Top-level side effects retain their entire reference chain. A prototype mutation that references a class keeps that class and everything it depends on, even if the mutation’s target is never used. Isolate side-effectful setup in a separate module that is imported only when needed .
- Computed symbol keys prevent class elimination. esbuild cannot prove that the computed expression is side-effect-free, so it keeps the class. Splitting the class into its own module allows the module to be removed via
sideEffects: falseeven though the class itself is not removable within the module . - The global allowlist expands over time.
IteratorandAtomicswere added in esbuild 0.25.12, allowing subclasses to be tree-shaken. Upgrading esbuild can make previously retained code removable without any source changes . - Tree shaking is a contract between the author and the bundler. The author declares what is side-effect-free; the bundler removes what the declaration permits. A false declaration produces silent runtime bugs; a missing declaration produces a larger bundle. The declaration is not optional for a library that wants to be tree-shakeable.
Remember: esbuild’s tree shaking is conservative because it must be. A bundler that removes code it cannot prove is side-effect-free is a bundler that introduces bugs. The conservative default is the safe default. The work of optimizing a bundle is the work of making the code legible to that conservatism: moving side effects out of the hot path, splitting symbol-keyed classes into their own modules, and declaring sideEffects accurately in every package. The bundler does not know what your code does. Your job is to tell it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!