Frontend Performance/Shipping Less JavaScript
Lesson 16 of 20 · Episode 16

Tree Shaking

How bundlers drop the code you never use — and why ESM, named imports, and sideEffects make or break it.

Tree shakingESMDead codesideEffects
Watch on YouTube ↗

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.

The idea

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.

See it

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:

Interactivemath-utils, tree-shaken
Toggle which functions your app actually calls:
import { add, formatCurrency } from "math-utils";
math-utils module
add()0.3 KB
subtract()0.3 KBSHAKEN
multiply()0.3 KBSHAKEN
divide()0.4 KBSHAKEN
formatCurrency()2.2 KB
parseDate()3.6 KBSHAKEN
buildChart()14 KBSHAKEN
Final bundle
2.5 KB
of 21.1 KB total
Shook out 18.6 KB of unused code.
With a named ESM import, only the functions you call survive. With import * or require(), the bundler can't prove what's unused — so everything ships.
Requirement #1

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.

static (analyzable) vs dynamic (opaque)
// 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 this
Requirement #2

Named 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.

prefer named exports/imports
// ❌ 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);
Requirement #3

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:

InteractivePure (shakable) or side-effecting (kept)?
0/5 sorted
sum(a, b) => a + b
addToTotal(x) => window.total += x
track(e) => events.push(e)
format(n) => `$${n.toFixed(2)}`
import "./polyfill" (patches globals)
Tell the bundler it's safe
In 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 gotcha

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.

babel.config — keep ESM intact for the bundler
presets: [
  ["@babel/preset-env", { modules: false }]  // don't convert import → require
]
Q1Multiple choice
Why does tree shaking work with ESM but not CommonJS?
Q2Multiple choice
You import one helper but the whole library ships. Most likely cause?
Q3Multiple choice
Why won't the bundler remove a function whose return value you never use?
Q4Multiple choice
Tree shaking stopped working after adding Babel. The fix?
Key takeaways
  • 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 sideEffects in package.json.
  • Watch Babel: set modules: false so it doesn't turn ESM into CJS.
← Previous
15. Resource Hints — prefetch
Next →
17. Bundle Analyzer