How does tree shaking remove unused code in bundlers
Tree shaking is dead-code elimination enabled by ES module static structure: imports/exports are statically analyzable, so the bundler builds a dependency graph, marks which exports are actually used, and drops the rest. Requires ESM (not CommonJS), and is hindered by side effects — hence `sideEffects: false`.
Tree shaking is dead-code elimination — and it works because of one property of ES modules: their structure is statically analyzable.
Why ESM makes it possible
ES module import/export statements are static — they're at the top level, can't be conditional, and the bindings are known at build time without running the code:
import { used } from "./utils"; // statically analyzableCommonJS (require()) is dynamic — require is a function call that can be conditional, computed, anywhere. The bundler can't statically know what's used. So tree shaking requires ES modules.
How the bundler does it
- Build the module dependency graph — starting from the entry point, follow every
import. - Mark used exports — for each module, determine which exported bindings are actually referenced (reachability analysis from the entry).
- Drop the unused — exports never reached are excluded from the bundle. Then the minifier removes the now-dead code.
So if a utils file exports 20 functions and you import 1, the other 19 (and their exclusive dependencies) don't ship.
What breaks / hinders it
- Side effects. If a module does something just by being imported —
import "./polyfill", code that mutates globals, a CSS import — the bundler can't safely remove it even if no export is used; removing it would change behavior. Bundlers are conservative by default. - The fix:
"sideEffects": falseinpackage.jsontells the bundler "this package's modules have no import-time side effects, it's safe to drop unused ones." Or list the files that do have side effects. - CommonJS modules — not shakeable; a single CJS dependency can bloat the bundle.
- Re-export barrels (
index.jsre-exporting everything) can pull in more than expected if not handled well. - Importing the whole namespace —
import * as _ from "lodash"orimport lodash from "lodash"defeats it; useimport { debounce } from "lodash-es"(the ESM build).
Practical takeaways
- Ship/consume ESM builds.
- Use named imports, not default/namespace imports of big libraries.
- Mark packages
sideEffects: falsewhen true. - It's why
lodash-esexists and why library authors ship ESM.
The framing
"Tree shaking is dead-code elimination, and it's possible because ES modules are statically analyzable — import/export are top-level and known at build time, unlike CommonJS require, which is a dynamic function call. The bundler builds the dependency graph from the entry, marks which exports are actually referenced, and drops the unreachable ones for the minifier to strip. The big limiter is side effects — a module that does work just by being imported can't be safely removed, which is why sideEffects: false in package.json matters. Practically: use ESM, use named imports, don't namespace-import big libraries."
Follow-up questions
- •Why can't CommonJS modules be tree-shaken?
- •What does `sideEffects: false` in package.json do?
- •Why does `import * as _ from 'lodash'` defeat tree shaking?
- •What kinds of code count as a 'side effect' the bundler can't remove?
Common mistakes
- •Thinking tree shaking works on CommonJS modules.
- •Namespace or default importing a large library instead of named imports.
- •Not setting sideEffects: false on a side-effect-free package.
- •Assuming the bundler can always tell what's safe to remove — side effects block it.
Performance considerations
- •Tree shaking directly shrinks the bundle — smaller download, faster parse/execute, better TTI. It's one of the highest-leverage build optimizations, but only if the code is ESM and side-effect-annotated.
Edge cases
- •A module with side effects (polyfill, global mutation, CSS import).
- •Barrel files re-exporting everything.
- •A mixed ESM/CJS dependency.
- •Dynamic imports with computed specifiers.
Real-world examples
- •Importing { debounce } from lodash-es and shipping only debounce, not all of lodash.
- •Library authors publishing ESM builds with sideEffects: false so consumers shake unused exports.