TL;DR / Tree shaking eliminates unused exports from your final bundle by leveraging the static structure of ES module imports at compile time.
How It Works
┌────────────────┐ ┌──────────────────┐
│ app.js │ │ utils.js │
│ import { a } │─────────→│ export a, b, c │
└────────────────┘ └──────────────────┘
│
│
↓
┌────────────────┐ ┌──────────────────┐
│ Static │ │ Mark used: │
│ Analysis │─────────→│ a Y b X c X │
└────────────────┘ └──────────────────┘
│
┌─────────────┘
↓
┌──────────────────┐
│ Output bundle │
│ only includes a │
└──────────────────┘
Tree shaking is a dead code elimination technique that operates on the module graph. The term originates from the mental model of shaking a dependency tree until the dead leaves fall off. Unlike general-purpose dead code elimination (DCE), which operates on control flow within a single file, tree shaking specifically targets unused exports across module boundaries.
Why ES Modules Are Required
The entire mechanism depends on ES modules having a static structure. When you write import { a } from './utils', the bundler knows at compile time exactly which bindings are consumed. This is impossible with CommonJS because require() is a runtime function call — the argument could be a variable, a conditional expression, or computed dynamically. ES module import and export declarations are hoisted, cannot appear inside conditionals, and must use string literal specifiers. This gives bundlers a complete, static picture of the dependency graph before any code executes.
The Three-Phase Process
Phase 1: Module Graph Construction. The bundler starts at one or more entry points and recursively follows every import statement to build a complete graph of modules and their relationships. Each module node tracks which symbols it exports and which it imports from others.
Phase 2: Mark and Sweep. Starting from entry point imports, the bundler walks the graph marking every accessed export as "used." If module A imports foo from module B, foo is marked. If foo's implementation internally references bar from module C, bar is also marked. Everything not reachable from an entry point import remains unmarked. This is essentially a graph reachability algorithm — the same concept as mark-and-sweep garbage collection, but for code rather than memory.
Phase 3: Code Generation. During output, the bundler emits only the marked exports and their transitive dependencies. Unmarked exports — and any module-level code only reachable through them — are omitted entirely from the output.
Side Effects: The Complication
A module has side effects if merely importing it changes observable program state — writing to global variables, modifying prototypes, calling DOM APIs, or executing top-level code that does anything beyond defining functions and constants. Bundlers cannot safely remove a side-effectful module even if none of its exports are consumed, because removing it would change program behavior.
This is where "sideEffects" in package.json becomes critical. Setting "sideEffects": false tells the bundler that every module in the package is purely declarative, making it safe to prune any module whose exports are unused. You can also specify an array of file globs that do have side effects (e.g., CSS imports, polyfills) while marking the rest as safe to eliminate.
How Bundlers Differ
Rollup pioneered tree shaking and is still the most aggressive. It defaults to treating modules as side-effect-free unless it detects top-level code with observable effects. Webpack is more conservative — it preserves modules unless sideEffects: false is explicitly declared. esbuild performs tree shaking but with less granularity; it operates at the statement level within modules rather than the fine-grained binding level that Rollup achieves.
The /*#__PURE__*/ Annotation
Function calls at the top level of a module are assumed to have side effects because the bundler cannot generally prove otherwise. The /*#__PURE__*/ comment annotation tells the bundler that a specific call is safe to remove if its return value is unused. Babel, TypeScript, and build tools insert these annotations on class instantiation and helper function calls to improve tree shaking results.
Measuring Results
Use bundle analyzers like webpack-bundle-analyzer, rollup-plugin-visualizer, or source-map-explorer to verify that tree shaking is actually working. Look for modules from dependencies that should not be present. If a library re-exports everything through a barrel index.js, tree shaking can fail because accessing the barrel file triggers side effects from all re-exported modules.
Gotchas
- Barrel files kill tree shaking — a single
export * from './module'inindex.jscan force the bundler to include modules you never actually import, especially if any re-exported module has side effects. - CommonJS is immune to tree shaking — if a dependency ships only CJS, the bundler cannot statically determine which exports you use. Look for packages that ship an ESM build (usually via the
"module"or"exports"field inpackage.json). - Class declarations are hard to tree-shake — classes with decorators, static initializers, or prototype mutations at the top level look like side effects to the bundler, preventing removal even when the class is never imported.
- Side effects in module scope — any top-level
console.log, global assignment, or IIFE prevents the bundler from removing the module, even if you only want one small utility from it. sideEffects: falsecan break CSS — if your components import.cssfiles (a side-effect-only import), marking your package as side-effect-free will cause the bundler to drop those CSS imports entirely.