Quick Answer

Tree shaking is dead-code elimination a bundler performs on ES module imports — if you import a function but never call it, the bundler can prove that and leave it out of the final bundle entirely. It only works reliably with ES module import/export syntax, because that structure is knowable without running the code; CommonJS require() can happen conditionally at runtime, so bundlers usually can't prove it's safe to remove anything. A module that runs code as soon as it loads gets kept in the bundle regardless of whether you use anything it exports.

What tree shaking actually removes

Take a module with two exported functions, where only one is ever imported:

// utils.js
export function add(a, b) {
  return a + b;
}

export function multiplyNumbersTogether(a, b) {
  return a * b;
}

// main.js
import { add } from './utils.js';
console.log(add(2, 3));

Bundling main.js with esbuild:

esbuild main.js --bundle --format=esm --outfile=out.js

produces exactly this, and nothing else:

// utils.js
function add(a, b) {
  return a + b;
}

// main.js
console.log(add(2, 3));

multiplyNumbersTogether never appears in the output — not commented out, not minified into something unreadable, simply absent. The bundler traced every import back from main.js, determined nothing referenced the second function, and left it out entirely. This is the entire idea: your source code can define far more than any one entry point actually uses, and the bundle only has to contain what's reachable.

Why the same code in CommonJS doesn't get shaken

Rewrite the exact same two functions using CommonJS instead of ES module syntax:

// utils.js
function add(a, b) { return a + b; }
function multiplyNumbersTogether(a, b) { return a * b; }
module.exports = { add, multiplyNumbersTogether };

// main.js
const { add } = require('./utils.js');
console.log(add(2, 3));

Bundling this version, the size difference tells the story before you even open the file:

out.js (ESM source):        95 bytes -- multiplyNumbersTogether is gone
out.js (CommonJS source):  567 bytes -- multiplyNumbersTogether is still there, twice

The unused function survives — twice, in fact, because esbuild's CommonJS interop wraps it inside a module function and also lists it in the exports object it builds at runtime. The reason is structural: module.exports = { add, multiplyNumbersTogether } builds a plain object at runtime, and a bundler cannot always prove which properties of that object anyone will read later, especially once you consider that require() calls can be conditional (if (x) require('./a') else require('./b')) in a way that import statements structurally cannot be. ES module imports are declared at the top of a file and can't be computed at runtime, which is exactly what makes them safe to analyze statically before running anything.

The gotcha: a side effect keeps the whole module alive

Add one line to the ES module version — a console.log that runs the moment the file loads, unrelated to either export:

// utils.js
console.log('utils module evaluated'); // runs the instant the module loads
export function add(a, b) { return a + b; }
export function multiplyNumbersTogether(a, b) { return a * b; }

// main.js
import { add } from './utils.js';
console.log(add(2, 3));

Bundling this produces:

// utils.js
console.log("utils module evaluated");
function add(a, b) {
  return a + b;
}

// main.js
console.log(add(2, 3));

Notice both things happening at once: the console.log survives, but multiplyNumbersTogether is still gone. Tree shaking isn't all-or-nothing per module — it's per-declaration. The bundler can prove the unused function is safe to delete because calling it or not calling it changes nothing observable. It cannot make that same claim about a statement that runs unconditionally at the top of the file, because removing it would change what the program does the moment it loads. This is exactly why importing "just one icon" from a large icon library can still drag in setup code: if that library's files run anything at module load time, a bundler has to keep that part regardless of which icon you actually asked for.

The package.json sideEffects field

Library authors can tell bundlers explicitly which files are safe to drop entirely when unused, via a sideEffects field in package.json. Setting it to false tells the bundler: none of this package's files do anything observable just by being loaded, so if nothing imports from a given file, skip evaluating it altogether — a stronger promise than per-export shaking, made by the author rather than proven by static analysis.

The common mistake with this field is applying it to a package that also ships CSS imports, like import './button.css' inside a component file. A blanket "sideEffects": false tells the bundler that stylesheet import is also safe to drop if the component's JS export looks unused in some code path — and production builds have shipped completely unstyled because of exactly this. The fix is to list the exceptions explicitly instead of a blanket false:

{
  "name": "my-ui-library",
  "sideEffects": ["*.css"]
}

This says: treat everything else in this package as side-effect-free and shake it aggressively, but never drop a CSS file just because nothing in your analysis appears to reference it.

Why importing one function from lodash still costs 70KB

This isn't a toy problem — it's the exact reason lodash-es exists as a separate package from lodash. Bundling the same single import, debounce, from each package with esbuild's minifier:

// bundled with esbuild --bundle --minify, importing ONLY debounce

import { debounce } from 'lodash';      // -> 71.6kb minified
import { debounce } from 'lodash-es';   // -> 2.9kb minified

// both produce a working debounce -- same behavior, very different cost

Both bundles run identically — same debounce behavior, same output when executed. The 25x size difference comes from exactly what the earlier sections covered: plain lodash is written as CommonJS, so requesting one function still pulls in the whole exports object the bundler can't safely prune from. lodash-es ships the same functions as separate ES modules, so a bundler can trace the import back to just debounce and its direct dependencies, and leave the rest of the several-hundred-function library out entirely. Whenever a library offers both a plain and an -es or esm flavored package, this is the difference you're choosing between.

Frequently Asked Questions

Does tree shaking work with require()? Generally no, or only very weakly. CommonJS's require() and module.exports are structured in a way that's hard to analyze statically, so most bundlers can't prove it's safe to remove anything from a required module.
Do I need a special bundler config to enable tree shaking? Modern bundlers like esbuild, Rollup, and Webpack (in production mode) enable it by default for ES module code. The bigger lever is usually making sure your own code and dependencies actually use ES module syntax rather than CommonJS.
Why does my bundle still include code I never call? The most common causes are a dependency written in CommonJS, a top-level side effect in a module you partially use, or a package.json sideEffects setting that doesn't match what the package actually does.
What's the difference between tree shaking and minification? Tree shaking removes entire unused declarations before the code is emitted. Minification shrinks the code that remains — shorter variable names, no whitespace — without deciding what to keep or remove.
Does tree shaking work in development mode? Bundlers can technically do it in any mode, but most development builds skip it for faster rebuild times and leave dead code elimination for the production build, so bundle size differences often only show up once you build for production.