Tree Shaking — How It Actually Works, What Defeats It
TL;DR
Tree shaking is the bundler’s dead-code elimination at the ES module level — exports that no one imports get dropped from the final bundle. It requires static ES module imports (import { x } from "lib", not require() or dynamic import() with runtime paths), sideEffects: false in the library’s package.json (or a list), and code that the bundler can analyze without running it. Defeated by: CJS dependencies, side-effect-ful module bodies, dynamic property access, and re-export barrels that pull everything.
Interview Q&A
Q: What is tree shaking, conceptually?
A: The bundler builds a dependency graph of all modules and their exports. Any export not reached from the entry point gets dropped from the output. The “tree” is the module graph; “shaking” drops dead leaves.
For a tree shake to drop an export, the bundler must prove:
- The export is never imported anywhere reachable from the entry.
- The module’s body has no side effects that the bundler would lose by skipping it.
Both conditions must hold. Either one fails → the export ships.
Q: Why does tree shaking require ES modules?
A: ESM imports/exports are statically analyzable — declared at the top of a file, names visible without running code:
// ESM — bundler can see what's imported
import { debounce } from "lodash-es";
CJS requires are dynamic — they run code:
// CJS — bundler can't statically analyze
const { debounce } = require("lodash");
const fn = require(someDynamicPath);
The bundler can sometimes analyze simple CJS patterns, but the general case (computed requires, conditional requires) defeats static analysis. ESM was designed partly to enable tree shaking.
Q: What does sideEffects mean in package.json?
A: It tells the bundler whether importing a module for its side effects only is meaningful — i.e., whether the module body (imports’ module-level code) needs to run even if no exports are used.
// package.json — claims this whole package has no side effects
{
"sideEffects": false
}
// or explicit list of side-effectful files (typically CSS)
{
"sideEffects": ["*.css", "./src/polyfills.js"]
}
With sideEffects: false, the bundler can safely drop an entire module if nothing’s imported from it. Without it, the bundler conservatively keeps the module body even if no exports are used.
A library author saying sideEffects: false is making a contract: “my module bodies don’t register globals, don’t mutate, don’t do anything observable unless you call my exports.” If untrue, consumers will hit weird “this worked when I imported it directly, broke when I added a .babelrc” bugs.
Q: Show me tree shaking working / failing.
A:
// lib.js
export function used() { return "used"; }
export function unused() { return "unused"; }
// app.js
import { used } from "./lib.js";
console.log(used());
With ESM + bundler tree shaking, the output contains only used — unused is gone.
Defeated example:
// lib.js — module body has a side effect
console.log("module loaded"); // side effect — bundler must keep
export function used() { ... }
export function unused() { ... }
The console.log runs on import; if the bundler drops the module, it loses observable behavior. With sideEffects: false declared, the bundler trusts you and may drop the unused exports anyway (you’d lose the console.log too).
Q: Why does import _ from "lodash" defeat tree shaking but import { debounce } from "lodash-es" doesn’t?
A:
lodashis CJS. Even named importsimport { debounce } from "lodash"are interop wrappers; the bundler can’t drop unused exports from a CJS module body.lodash-esis ESM withsideEffects: false. Bundlers can tree-shake to justdebounce.import _ from "lodash"(default import) imports the whole_object — every method is reachable through_.x. No tree shaking possible.
Rule: import named, from ESM packages.
For libraries that don’t ship ESM or are CJS-only, consider:
- A modern alternative (
date-fnsinstead ofmoment,dayjsinstead ofmoment, native JS instead of small lodash methods). - A “babel-plugin-lodash”-style transformer that rewrites
import _ from "lodash"into specific paths.
Q: What’s a “barrel file” and why is it sometimes a tree-shaking footgun?
A: A barrel is an index.js/index.ts that re-exports many submodules:
// components/index.ts (barrel)
export * from "./Button";
export * from "./Input";
export * from "./Modal";
// 50 more...
Consumer:
import { Button } from "./components";
If your bundler is smart and the components have sideEffects: false, only Button ships. If your bundler is conservative or anything along the chain has side effects, all 50 components get pulled in.
In practice:
- Modern bundlers (esbuild, Vite, Rollup) handle barrels well if sideEffects is correctly declared all the way down.
- Webpack is more cautious — barrel re-exports historically required extra effort to shake.
- TypeScript path mapping with barrels can confuse some analyzers.
The pragmatic recommendation: prefer deep imports in library code (import { Button } from "@your/ui/Button") and only use barrels in app code where the cost is small.
Q: How do you verify tree shaking actually worked?
A: Bundle analyzer (webpack-bundle-analyzer, rollup-plugin-visualizer) — see whether the unused exports are absent from the output.
Smoke test: import a single named function from a library; build; check the bundle size. If it’s the size of the whole library, tree shaking didn’t work.
Modern bundlers also expose “why included” info — webpack: --profile, esbuild’s metafile + visualizer show what kept which module.
Q: Tree shaking and CSS — what’s the equivalent?
A: Tree shaking is for JS modules. For CSS:
- PurgeCSS (or Tailwind’s built-in JIT/purge) — scans your templates for class usage, removes unused selectors.
- CSS Modules with bundler-aware imports — only used
.module.cssfiles ship. - Critical CSS extraction — inline the above-the-fold styles, defer the rest.
Tailwind’s content-scan + JIT compilation is the modern winner for “ship only what I use.” Per-component CSS-in-JS (Emotion, styled-components) is implicitly per-component but has runtime cost.
Q: What about polyfills?
A: core-js and similar pull in polyfills based on your browserslist target. Even with tree shaking, if you target IE 11, you get the polyfill for Array.from. Audit browserslist — most apps no longer need IE/legacy polyfills.
// package.json
"browserslist": [
">0.5%",
"last 2 versions",
"not dead",
"not IE 11"
]
Removing IE 11 from your target can shave 30-100 KB.
Q: ESM vs CJS interop — what gives?
A: Node and bundlers have a complicated dance with mixed ESM/CJS. Key points:
- A CJS package can be imported into ESM (
import foo from "cjs-pkg"), but named imports may not work:import { foo } from "cjs-pkg"; // may error or be undefined import pkg from "cjs-pkg"; const { foo } = pkg; // safe fallback - A
package.json"exports"map can declare both:"exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } - Dual-package hazard: importing both ESM and CJS builds of the same package creates two separate module states. Singletons break. The “exports” map prevents this but you have to set it right.
For tree shaking: insist on packages with "module" or "exports" pointing to ESM.
Gotchas / edge cases
- A library that
console.logs on import defeats tree shaking unlesssideEffects: false. - Class instances with static side effects (registering with a global registry on definition) — same problem.
*.cssimports are side effects — listed insideEffectsarray typically.- TypeScript
enum— emits IIFE; bundlers may keep them. Useconst enum(zero-cost) or string-literal unions to be safe. ?.and??operators can defeat tree shaking in older bundlers — modern ones handle fine.- Bundler bug / library mis-declaration — sometimes you trace a “why is this in my bundle?” to an upstream mistake. File a bug, or work around with
babel-plugin-transform-imports/ direct deep imports.
What a senior is expected to say
- “Tree shaking needs static ESM imports +
sideEffects: false. Either condition missing and the bundler conservatively keeps the code.” - “Import named from ESM packages —
import { debounce } from 'lodash-es', notimport _ from 'lodash'. The latter ships the whole library.” - “Side effects are the contract — a library claims
sideEffects: falseto say its module bodies are safe to drop if exports go unused. CSS imports are listed explicitly.” - “Barrels can be tree-shaking footguns with conservative bundlers; prefer deep imports in library code.”
- “CSS tree-shaking is a different mechanism — PurgeCSS / Tailwind’s JIT for that side.”
- “Verify with a bundle analyzer; trust nothing about tree shaking ‘working in theory.’”
Cross-references
- Bundle analysis (where you spot the failures): 03_bundle_analysis_and_code_splitting.md
- Build tools (how bundlers actually do tree shaking): ../09_build_tools/
- Lazy loading (the runtime equivalent of bundle splitting): 05_lazy_loading.md
Further reading
- Webpack — Tree Shaking: https://webpack.js.org/guides/tree-shaking/
- Rollup — Tree Shaking: https://rollupjs.org/faqs/#what-is-tree-shaking
- web.dev — Tree shaking & unused code: https://web.dev/articles/reduce-javascript-payloads-with-tree-shaking
- “sideEffects” field reference: https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free