Frontend / Performance / 04_tree_shaking.md

Tree Shaking — How It Actually Works, What Defeats It

Updated 6 interview angles 7 min read source
On this page14
  1. What is tree shaking, conceptually?
  2. Why does tree shaking require ES modules?
  3. What does sideEffects mean in package.json?
  4. Show me tree shaking working / failing.
  5. Why does import _ from "lodash" defeat tree shaking but import { debounce } from "lodash-es" doesn’t?
  6. What’s a “barrel file” and why is it sometimes a tree-shaking footgun?
  7. How do you verify tree shaking actually worked?
  8. Tree shaking and CSS — what’s the equivalent?
  9. What about polyfills?
  10. ESM vs CJS interop — what gives?
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Tree Shaking — How It Actually Works, What Defeats It

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.

What is tree shaking, conceptually?

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:

  1. The export is never imported anywhere reachable from the entry.
  2. 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.

Why does tree shaking require ES modules?

ESM imports/exports are statically analyzable — declared at the top of a file, names visible without running code:

js
// ESM — bundler can see what's imported
import { debounce } from "lodash-es";

CJS requires are dynamic — they run code:

js
// 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.

What does sideEffects mean in package.json?

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.

json
// 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.

Show me tree shaking working / failing.

js
// lib.js
export function used() { return "used"; }
export function unused() { return "unused"; }
js
// 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:

js
// lib.js — module body has a side effect
// side effect — bundler must keep
console.log("module loaded");
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).

Why does import _ from "lodash" defeat tree shaking but import { debounce } from "lodash-es" doesn’t?

  • lodash is CJS. Even named imports import { debounce } from "lodash" are interop wrappers; the bundler can’t drop unused exports from a CJS module body.
  • lodash-es is ESM with sideEffects: false. Bundlers can tree-shake to just debounce.
  • 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-fns instead of moment, dayjs instead of moment, native JS instead of small lodash methods).
  • A “babel-plugin-lodash”-style transformer that rewrites import _ from "lodash" into specific paths.

What’s a “barrel file” and why is it sometimes a tree-shaking footgun?

A barrel is an index.js/index.ts that re-exports many submodules:

ts
// components/index.ts (barrel)
export * from "./Button";
export * from "./Input";
export * from "./Modal";
// 50 more...

Consumer:

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

How do you verify tree shaking actually worked?

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.

Tree shaking and CSS — what’s the equivalent?

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.css files 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.

What about polyfills?

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.

json
// package.json
"browserslist": [
  ">0.5%",
  "last 2 versions",
  "not dead",
  "not IE 11"
]

Removing IE 11 from your target can shave 30-100 KB.

ESM vs CJS interop — what gives?

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:
    js
    // may error or be undefined
    import { foo } from "cjs-pkg";
    import pkg from "cjs-pkg";
    const { foo } = pkg;                  // safe fallback
  • A package.json "exports" map can declare both:
    json
    "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

  • A library that console.logs on import defeats tree shaking unless sideEffects: false.
  • Class instances with static side effects (registering with a global registry on definition) — same problem.
  • *.css imports are side effects — listed in sideEffects array typically.
  • TypeScript enum — emits IIFE; bundlers may keep them. Use const 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.

Further reading

Interview angle 6

  • “What does tree shaking require?” - static ESM imports so the bundler can prove which exports are used, and a "sideEffects": false declaration so it knows dropping an unused module changes nothing observable. Miss either and it conservatively keeps the code, which is the correct behaviour.
  • “What does sideEffects: false actually promise?” - that evaluating a module body has no observable effect beyond defining its exports. It is a contract, and it is wrong for a module that registers something globally or imports CSS - which is why the field also accepts a list of the files that do have effects.
  • “Give the classic import mistake.” - import _ from "lodash" instead of import { debounce } from "lodash-es". The first is a CommonJS default import and pulls the entire library; the second is a named ESM import the bundler can narrow.
  • “How do barrel files interact with this?” - badly, with conservative bundlers. An index re-exporting fifty modules means the graph loads all fifty even if tree shaking later removes most of it, and any one of them with a side effect is kept. Prefer deep imports inside library code.
  • “Does tree shaking apply to CSS?” - no, that is a separate mechanism. Unused CSS removal is PurgeCSS or Tailwind’s compiler scanning your source for class names, and it has its own failure mode: dynamically constructed class names are invisible to it.
  • “How do you verify it worked?” - the bundle analyser. Tree shaking working in theory and working in your build are different claims, and only the second one matters.