Topics
v6.0.0
On this page · sections

Tree-shaking

Tree-shaking is dead export elimination on the static ES module graph at build time — not runtime minification. Interviewers test whether you understand reachable module graphs, not just toggling a bundler flag.

ConceptWhat it doesWhen it fails
Tree-shakingDrops unreferenced exports from ESM graphCJS require, dynamic access
sideEffectsAllows whole-file drops when unusedWrong on CSS/polyfill imports
usedExportsMarks which exports are referencedNeeds analyzable usage
MinifierRemoves dead code inside kept modulesCannot drop graph-marked reachable code

What Tree-shaking Is

Definition

The bundler starts at entry points, marks reachable imports, and drops exports no import chain references. Minifiers like Terser or esbuild then remove unused functions inside kept modules.

// lib.mjs export function keep() { return 'used'; } export function drop() { return 'unused'; } // index.mjs — entry import { keep } from './lib.mjs'; console.log(keep()); // production bundle should not include `drop`
Entry → build import graph → mark used exports → drop unused → minify dead code inside kept modules

Development builds often skip aggressive shake for speed. Vite dev serves native ESM with minimal transformation — no full shake. Production vite build uses Rollup, which shakes aggressively.

Rollup was designed around ESM graph shaking. Webpack 5 shakes ESM well with optimization.usedExports and correct sideEffects.

Interview: Say “reachable module graph” before naming Rollup or webpack — interviewers want the algorithm.

sideEffects in package.json

Definition

sideEffects tells the bundler whether importing a file can run top-level code with observable effects beyond exporting values.

  • false — if nothing imports this file, skip it entirely
  • Array form — "sideEffects": ["*.css", "./polyfill.js"] whitelists effectful files
  • Omitted — webpack assumes true (safe, larger bundles)
// package.json (utility library) // { "name": "my-utils", "sideEffects": false } // index.mjs — only exports, no top-level run export { debounce } from './debounce.mjs'; export { throttle } from './throttle.mjs'; // bad for sideEffects: false — top-level runs on import // import './register-polyfill.mjs';

usedExports marks which exports are referenced. sideEffects decides whether an unreferenced file can be skipped. Both cooperate in webpack’s optimization.sideEffects.

Real-world example

CSS imports and import './setup' polyfills are side effects — marking sideEffects: false on those packages causes production-only bugs that never appear in dev.

Interview: sideEffects: false on a package that mutates globals on import is a silent production bug.

Patterns That Defeat Tree-shaking

Definition

Shaking fails when the bundler cannot prove what is used:

// Defeats shaking — dynamic access import * as icons from './icons.mjs'; const name = 'Search'; const Icon = icons[name]; // keeps every export in icons.mjs // Shake-friendly — static import import { SearchIcon } from './icons.mjs';

Other defeat patterns:

  • Barrel export * pulling entire subsystems
  • CommonJS interop retaining module.exports
  • Babel converting ESM to CJS before bundler analysis
  • Top-level side effects, window assignments, static class blocks
// app-a.mjs — large bundle import _ from 'lodash'; console.log(_.chunk([1, 2, 3], 2)); // app-b.mjs — small bundle import chunk from 'lodash-es/chunk.js'; console.log(chunk([1, 2, 3], 2));

Default lodash is CJS — webpack imports the whole package or a large subset. lodash-es/chunk.js is a single ESM module.

Interview: import * as ns; ns[dynamicKey] forces O(all exports) retention per module.

Barrel Files & Subpath Exports

Definition

A barrel centralizes public API — export { Button } from './Button' — but export * from './charts' can drag entire subsystems into any consumer that imports one symbol.

// Good barrel — explicit, side-effect-free export { debounce } from './debounce.mjs'; export { throttle } from './throttle.mjs'; // Risky barrel // export * from './admin-dashboard.mjs'; // Consumer — prefer subpath when package supports it import { debounce } from 'my-lib/debounce';

Package.json exports subpaths — "./button": "./dist/button.js" — let consumers import deep modules without a mega barrel. Internal barrels OK in apps; library barrels need discipline.

TypeScript path aliases are compile-time — runtime graph unchanged.

Interview: Star barrels can widen the graph from O(1 module) to O(entire subsystem) per import.

Verifying Tree-shaking in Production

Definition

Do not trust source structure — inspect the prod bundle. Run an optimized build, open webpack-bundle-analyzer, Rollup visualizer, or source-map-explorer, and search for symbols that should be gone.

// scripts/check-shake.mjs — smoke test after build import { readFileSync } from 'node:fs'; const bundle = readFileSync('./dist/assets/index-abc123.js', 'utf8'); const forbidden = ['UNUSED_HEAVY_FEATURE_MARKER', 'function dropMe']; for (const token of forbidden) { if (bundle.includes(token)) { console.error('Tree-shake failed — found:', token); process.exit(1); } }
{ "size-limit": [ { "path": "dist/assets/index-*.js", "limit": "120 KB" } ] }

Staff approach: per-route async chunk budgets, library consumer tests that import one export and assert output lacks heavy strings, PR regression tracking on main chunk gzip.

Pair bundle analysis with RUM — small gzip can still parse slowly on low-end devices.

Interview: DevTools Network tab does not prove shaking — use production artifact analysis.

Senior Interview Summary

Algorithm

Entry → traverse import graph → mark reachable exports → drop unreachable → minify inside kept modules.

Prerequisites

Static ESM imports + accurate sideEffects metadata. Both are required — name them before tool names.

sideEffects: false

Unused files can be skipped entirely. Dangerous when any file has import-time side effects.

Defeat patterns

import * + dynamic key · CJS require · star barrels · hidden polyfills · Babel CJS transform.

Verification

Prod build + analyzer + string search + CI size budgets. Split chunks — admin code should not be in landing chunk.

Best mental model

Graph reachability → what exports survive sideEffects → whether whole files can be omitted Minifier → cleans inside survivors only

Practical rules

Library authors → audit every file before sideEffects: false Consumers → prefer named imports and subpath exports lodash migration → lodash-es per-function imports Barrels in libs → explicit named re-exports, not export *

What to say in interviews

Explain the two prerequisites (static imports + sideEffects), name three defeat patterns, and describe how you prove shaking worked in a prod artifact — not just “we use Rollup.”

Last updated on