How does 'tree-shaking' in a JavaScript bundler like webpack or Rollup actually decide what code to eliminate from a production bundle, and what has to be true about a dependency for tree-shaking to work on it?
answer
- ESM static imports vs CJS dynamic require
- sideEffects: false flag
- lodash barrel import problem
- Terser does actual deletion
- reachability graph from entry point
basics
~20 sTree-shaking is a bundler feature that deletes code you imported but never actually use, so your final downloaded JavaScript file is smaller — but it only works if the library is written in a way the bundler can statically analyze.
solid answer
~50 sTree-shaking works by statically analyzing ES module import/export statements — which, unlike CommonJS require, are declared at the top level with fixed, non-dynamic bindings — to build a dependency graph of exactly which exported symbols are actually referenced anywhere in the app. Anything exported but never imported (or imported but never used) gets marked dead and stripped, usually by a downstream minifier (Terser) that does the actual removal after the bundler marks things reachable/unreachable. This only works reliably when the library ships genuine ES module syntax (not transpiled-to-CommonJS output) and marks itself free of side effects — either via 'sideEffects': false in package.json or per-file markers — because the bundler must prove that removing an unused export can't change program behavior. Libraries like lodash notoriously break tree-shaking when imported as `import _ from 'lodash'` (pulls in everything) versus `import debounce from 'lodash/debounce'` or an ES-module distribution (which shakes properly).
go deeper
Should have a basic notion: tree-shaking removes unused code from the bundle to make it smaller.
Should know it relies on ES module import/export syntax and that CommonJS doesn't tree-shake well.
Should explain the side-effects problem and the sideEffects:false convention, and give a concrete example (like lodash) of a common tree-shaking pitfall.
Should discuss the authoring burden of shipping a properly tree-shakeable library, the risk of mis-flagging sideEffects, and connect it to broader bundle-size/performance strategy across an org's shared component libraries.
## What tree-shaking is **Tree-shaking** is dead-code elimination applied specifically to JavaScript module graphs: a build-time step, run by a bundler like webpack, Rollup, or esbuild, that determines which exported values from your dependencies (and your own code) are ever actually referenced by anything reachable from the app's entry point, and strips everything else out of the final bundle before it's minified and shipped to the browser. The motivation is straightforward — a library might export fifty utility functions, but if your app only calls three of them, shipping the other forty-seven down the wire to every user on every page load is pure waste, directly hurting page-load performance, which is why this matters enough to be a standard step in virtually every modern frontend build pipeline. ## Why it needs ES module syntax The mechanism depends critically on **ES module (ESM)** syntax — `import { x } from 'module'` and `export const x = ...` — because these are, by spec, **static**: the set of imported and exported bindings is fully determined at parse time, before any code runs, and can't be changed dynamically (you can't conditionally import a different name inside an `if` block the way you could with `require()` in CommonJS, where module loading is an ordinary runtime function call that could in principle be passed any string). That static, analyzable structure lets a bundler build an exact graph: 1. entry point imports A and B from module M; 2. module M's implementation of A imports C from module N; 3. therefore N's export C is 'reachable' and everything else N exports is not. **CommonJS** (`const _ = require('lodash')`, `module.exports = {...}`), by contrast, is opaque to static analysis in the general case — the bundler can't prove which properties of the exported object get used, or whether `require` calls might be dynamic — so packages that ship only a CommonJS build generally can't be tree-shaken at all; the whole module gets included if any part of it is imported. ## The side-effects requirement Beyond ESM syntax, the second requirement is proving the elimination is safe, which comes down to **side effects**. A module is 'side-effect-free' if importing it and not using any of its exports has zero observable effect on the program — no top-level code that mutates a global, registers a polyfill, attaches a CSS stylesheet, or logs something. - If a bundler can't prove that, it has to conservatively keep the module's top-level code even when none of its named exports are used, because deleting it could silently change behavior (imagine a module whose only job is `Array.prototype.includes = polyfillFn` at import time — nothing 'uses' an export, but removing the import breaks the app on older browsers). - Since proving this automatically for arbitrary code is hard, the ecosystem convention is a `package.json` field, `'sideEffects': false` (or an array of specific files that do have side effects, e.g. CSS entry points), that lets the library author explicitly promise the bundler 'everything else here is safe to drop if unused.' - Without that flag, most bundlers default to the conservative, safe assumption and keep more code than strictly necessary. ## Where real libraries go wrong This is where real libraries commonly go wrong in practice, and **lodash** is the textbook example: `import _ from 'lodash'` pulls in the entire default-exported object, and because the library historically shipped in a form a bundler generally can't fully attribute per-method, the whole library — tens of kilobytes — ends up in the bundle even if you only called `_.debounce`. The two standard fixes are: | Fix | How it works | |---|---| | Importing the specific submodule directly (`import debounce from 'lodash/debounce'`) | Sidesteps the analysis problem entirely by only ever importing exactly the file you need | | Using an ESM-native distribution built for this purpose (`lodash-es`) | Ships proper per-function ES modules that a bundler can shake correctly when you do `import { debounce } from 'lodash-es'` | The same class of problem recurs across the ecosystem with any large utility or component library, which is why many such libraries now ship explicit per-module ESM entry points and `'sideEffects': false` metadata specifically to make tree-shaking work by default rather than by developer effort. ## The trade-off worth naming The trade-off worth naming explicitly is that none of this is free at the tooling or authoring level: - Shipping a properly tree-shakeable library requires maintaining an ESM build target (often alongside a CommonJS build for Node consumers who don't yet support ESM), auditing every module for accidental side effects, and testing that consumer bundlers actually shake as expected — a genuinely side-effect-free-looking module that turns out to run polyfill code at import time will silently break someone's production app the moment they upgrade and their bundler starts eliminating it. - On the consumer side, developers have to actually import named/specific bindings rather than default/barrel imports to get the benefit, which is easy to get wrong without linting rules enforcing it.
- Why can't a bundler tree-shake a CommonJS module the way it can an ES module?CommonJS's require() and module.exports are ordinary runtime function calls and object assignments, not a static, spec-fixed syntax, so a bundler generally can't prove ahead of time which exported properties are used or whether requires might be conditional/dynamic. It has to conservatively include the whole module rather than risk breaking behavior.
- What does the package.json 'sideEffects': false field actually do, and what happens if a library sets it incorrectly?It tells the bundler it's safe to drop any file in the package whose exports aren't used, without keeping the file's top-level code around just in case. If a library sets it to false while some file actually does something on import (like attaching a global polyfill or injecting CSS), that code can get silently eliminated from consumers' bundles, causing a hard-to-diagnose production bug only in the tree-shaken build.
- How does the choice between `import _ from 'lodash'` and `import debounce from 'lodash/debounce'` affect the final bundle size?The first imports the entire lodash default-exported object, and because a bundler generally can't prove which of its methods are actually called, the whole library ends up bundled. The second imports only the specific file implementing debounce, so only that function (and its own dependencies) makes it into the bundle, regardless of tree-shaking analysis.
Tree-shaking is like packing for a trip by only grabbing the specific items you listed off a shared shelf, versus grabbing the whole shelf because it's bolted together and you can't tell which items are actually yours to leave behind.
saying these in an interview costs you the question
- Thinks tree-shaking works on any JS file regardless of module format
- Unaware CommonJS blocks tree-shaking in the general case
- Doesn't know what sideEffects:false means or does
- Can't explain why lodash's default import is a known problem
- Thinks minification and tree-shaking are the same step/thing