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.
| Concept | What it does | When it fails |
|---|---|---|
| Tree-shaking | Drops unreferenced exports from ESM graph | CJS require, dynamic access |
sideEffects | Allows whole-file drops when unused | Wrong on CSS/polyfill imports |
usedExports | Marks which exports are referenced | Needs analyzable usage |
| Minifier | Removes dead code inside kept modules | Cannot 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 modulesDevelopment 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: falseon 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,
windowassignments, 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
sideEffectsmetadata. 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 onlyPractical 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.”