Tree Shaking
How bundlers drop the code you never use — and why ESM, named imports, and sideEffects make or break it.
You import one helper from a library — and somehow ship the whole thing. Tree shaking is the bundler's dead-code elimination: it walks your imports like a tree and drops every branch nothing references. But it only works if you give it code it can reason about. This lesson is about holding up your end of that bargain.
Dead-code elimination
Picture your code as a tree: the entry file is the trunk, every import a branch, every function a leaf. Tree shaking traces what's actually reachable from the trunk and shakes off the dead leaves — code that's defined but never referenced — so it never reaches the final bundle. Less JS to download, parse, and execute.
Watch code get shaken out
Here's a math-utils module with seven functions. Pick which ones your app calls, and which import style you use, then watch the final bundle shrink as the unused functions are shaken out:
Why ESM, not CommonJS
Tree shaking needs to know your dependency graph at build time, before the code ever runs. ES Modules make that possible: import/export are static — fixed at the top level, in a known shape the bundler can statically analyze. CommonJS require() is just a function call: it can sit inside an if, take a computed path, run conditionally — so the bundler can't prove what's used and gives up.
// ESM — static, top-level, predictable → tree-shakable
import { add } from "math-utils";
// CommonJS — a function call, could be anything → not shakable
const { add } = require("math-utils");
if (cond) { const x = require(someVar); } // good luck analyzing thisNamed over default imports
A default export hands the bundler the module as one opaque blob — touch any of it and you get all of it. Named exports let it track each function individually, so you only pay for what you import. This is why modern libraries (lodash-es, date-fns) ship as many small named exports.
// ❌ default export → whole module is one unit
import utils from "./utils";
utils.add(1, 2);
// ✓ named exports → each function is independently shakable
import { add } from "./utils";
add(1, 2);Side effects break it
The bundler will only remove code it's sure is safe to remove. A pure function — output depends only on its inputs, touches nothing outside itself — is obviously safe. A function with side effects (reads/writes a global, mutates external state, registers something) might be doing essential work even if its return value is unused, so the bundler keeps it. Classify these:
package.json, "sideEffects": false promises your package has no side effects, letting the bundler shake aggressively. Got a few files that do (a CSS import, a polyfill)? List them: "sideEffects": ["*.css", "./polyfill.js"].The Babel trap
Here's the one that silently ruins tree shaking: Babel. If its preset-env transpiles your ES Modules into CommonJS for older browsers, your nice static imports become require() calls before the bundler even sees them — and now nothing is shakable. The fix is one line: tell Babel to leave modules alone and let the bundler handle them.
presets: [
["@babel/preset-env", { modules: false }] // don't convert import → require
]- →Tree shaking = build-time dead-code elimination; drops code nothing references.
- →It needs ES Modules (static) — CommonJS
require()is too dynamic to analyze. - →Prefer named exports/imports over default so each function is independently shakable.
- →Write side-effect-free code; declare
sideEffectsin package.json. - →Watch Babel: set
modules: falseso it doesn't turn ESM into CJS.