Back to Performance
Performance
medium
mid

How do tree shaking and bundling work in modern JavaScript builds?

Tree shaking is dead-code elimination over ES modules. Static `import`/`export` syntax lets bundlers analyze the dependency graph and drop exports nothing imports. Side effects (or `sideEffects: false`) decide what's safely removable.

6 min read·~12 min to think through

Bundling packs your modules + dependencies into one or more files the browser can load efficiently (fewer HTTP requests, shared code split into chunks).

Tree shaking is dead-code elimination across modules: if a module exports A, B, C and only A is imported anywhere in the graph, B and C never reach the bundle.

It works because ESM is statically analyzable:

  • import { x } from "./m" is resolvable at parse time — no runtime branches, no require() returning different shapes.
  • The bundler builds a graph of what's exported vs what's actually imported and drops the rest.

What kills tree shaking:

  1. CommonJS (require/module.exports) — dynamic by design; bundlers can't reason about which exports are used.
  2. Side effects on importimport "./styles.css" registers global CSS; even if no symbol is used, the import must run. Mark side-effect-free packages with "sideEffects": false in their package.json.
  3. Re-exports through barrel filesexport * from "./big-file" can pull large surfaces unless re-exports are themselves marked as ESM with side-effect annotations.
  4. **/#__PURE__/ annotations missing** — toplevel function calls that return values are assumed to have side effects; bundlers need a hint to drop them.

Bundler flavors:

  • Webpack — workhorse; tree shaking via UglifyJS/Terser with sideEffects field.
  • Rollup — pioneered ES-module tree shaking; great for libraries.
  • esbuild / SWC — fast, used by Vite under the hood.
  • Vite — dev uses native ESM (no bundling); prod uses Rollup.

Code

ts
// math.ts
export const sum = (a: number, b: number) => a + b;
export const huge = () => /* 200KB of stuff */ {};

// app.ts
import { sum } from "./math";
sum(1, 2);
// Bundled output drops 'huge'
ESM tree-shakable
ts
{
  "name": "my-lib",
  "sideEffects": false,
  "exports": "./dist/index.js"
}

// Or list specific files that DO have side effects:
// "sideEffects": ["./dist/polyfills.js", "*.css"]
Mark a package side-effect-free in package.json

Follow-up questions

  • Why doesn't tree shaking work on CommonJS imports?
  • What does `"sideEffects": false` in package.json actually do?
  • How does Vite's dev server avoid bundling, and why is that fast?

Common mistakes

  • Importing a default export of a CJS package and assuming tree shaking works.
  • A barrel file (`index.ts` re-exporting everything) defeats granular imports unless side-effect hints are right.
  • Forgetting that CSS imports always have side effects — tree shaking won't strip styles.

Performance considerations

  • Tree shaking primarily reduces *bundle size* (LCP, parse time). It doesn't help runtime CPU.

Edge cases

  • Class methods can't be tree-shaken individually — the class is one unit.
  • TypeScript decorators / metadata may force a method to be retained for runtime reflection.

Real-world examples

  • lodash-es is tree-shakable; importing `{ debounce }` ships ~2KB instead of lodash's ~70KB.

Senior engineer discussion

Senior signal: discuss module-graph analysis, why mode='production' enables Terser, the role of `__PURE__` annotations, and how bundlers compose with route-level code splitting.

Related questions