Tree Shaking Internals
Dead Code You're Shipping
Here's a fun thought experiment: you import one function from a utility library. The library has 200 functions. Are you shipping all 200 to your users? Without tree shaking, yes. Yes, you are.
Tree shaking is dead code elimination for ES modules. The name comes from the mental model of "shaking a tree" — unused branches (exports) fall off, and only the live ones remain.
But here's what most developers miss: tree shaking is not magic. It has strict requirements, common failure modes, and subtle gotchas that cause most real-world bundles to ship far more dead code than you'd expect.
Think of tree shaking like pruning a dependency graph. The bundler starts from your entry point and walks every import statement, marking each export it actually uses. At the end, any export that was never marked is dead code — it can be safely removed. But this only works if the bundler can see the complete graph at build time. Dynamic patterns (require(variable), conditional exports) make branches invisible, and invisible branches can't be pruned.
Why ESM Is Required
So why can't you tree-shake everything? Because tree shaking fundamentally depends on ES modules' static structure. Three properties make the whole thing possible:
1. Imports and exports are top-level
// ESM — imports must be at the top level
import { formatDate } from './utils'; // Always resolved at parse time
// CommonJS — require can appear anywhere
if (process.env.NODE_ENV === 'development') {
const debug = require('./debug'); // Conditional — bundler can't know at build time
}
2. Import specifiers are string literals
// ESM — the path is a static string
import { format } from 'date-fns';
// CommonJS — the path can be a variable
const lib = require(getLibName()); // Dynamic — bundler can't resolve at build time
3. Exports are statically enumerable
// ESM — every export is declared in the source
export function formatDate() { /* ... */ }
export function formatCurrency() { /* ... */ }
// Bundler knows exactly what this module offers
// CommonJS — exports are runtime object assignments
module.exports[dynamicKey] = someValue;
// Bundler cannot enumerate exports without running the code
These three properties allow the bundler to analyze the entire module graph at build time, without executing any code. It builds a graph of which exports are used and which aren't, then removes the unused ones.
How Bundlers Mark Used Exports
Now let's look at what actually happens inside the bundler. The tree-shaking algorithm has three phases:
Phase 1: Build the module graph
The bundler starts from the entry point and follows every import statement recursively, building a complete graph of all modules and their dependencies.
Phase 2: Mark used exports (harmony exports)
Starting from the entry point's imports, the bundler marks each used export. It then follows the internal dependencies of those exports, marking transitively used exports.
// utils.js
export function formatDate(d) { return d.toISOString(); } // ← marked: used by app.js
export function formatCurrency(n) { return `$${n.toFixed(2)}`; } // ← NOT marked: nobody imports this
export function parseCSV(text) { return text.split('\n'); } // ← NOT marked: nobody imports this
// app.js
import { formatDate } from './utils'; // Only formatDate is needed
console.log(formatDate(new Date()));
Phase 3: Eliminate dead code
In the output bundle, unmarked exports are removed. The minifier (Terser) then removes any now-unreachable code.
// Output after tree shaking + minification
function formatDate(d){return d.toISOString()}
console.log(formatDate(new Date));
// formatCurrency and parseCSV are gone
The sideEffects Field
Tree shaking removes unused exports. Great. But what about modules that do something just by being imported — without any export being used?
// polyfill.js — no exports, but running it has an effect
Array.prototype.customMethod = function() { /* ... */ };
// analytics.js — registers a global listener on import
window.addEventListener('click', trackClick);
// styles.css — importing CSS has a visual side effect
import './global-styles.css';
These are side effects — observable behavior caused by importing a module, separate from its exports. If the bundler removes a module with side effects, the program's behavior changes.
The sideEffects field in package.json tells the bundler which files are safe to skip if their exports are unused:
{
"name": "my-library",
"sideEffects": false
}
sideEffects: false means "every file in this package is pure — if its exports aren't used, the entire file can be dropped safely."
For packages with some side-effectful files:
{
"sideEffects": [
"*.css",
"./src/polyfills.js"
]
}
This tells the bundler: CSS files and polyfills.js have side effects (keep them always). Everything else is pure and can be shaken.
Why CommonJS Defeats Tree Shaking
This is the part that burns people. Even in a project using ESM, one CommonJS dependency can prevent tree shaking for everything it touches.
// ESM — tree-shakable
import { pick } from 'lodash-es';
// Bundler imports only 'pick' and its dependencies
// CommonJS — NOT tree-shakable
const { pick } = require('lodash');
// Or even:
import { pick } from 'lodash'; // lodash's main entry is CommonJS
// Bundler must include the entire lodash module
The lodash package publishes CommonJS. lodash-es publishes ESM. Same functions, same API, but lodash-es tree-shakes to ~2KB for a single function while lodash ships 72KB regardless.
Many popular packages publish both CJS and ESM. The bundler uses the module or exports field in package.json to pick the ESM version. If these fields are missing or misconfigured, the bundler falls back to the main field (usually CJS), and tree shaking fails silently. Verify with: node -e "console.log(require.resolve('package-name'))" — if it resolves to a .cjs or unqualified .js in a lib/ or dist/ folder, it's likely CJS.
The Barrel File Trap
If there's one thing you take away from this section, let it be this: barrel files are the single most common tree-shaking failure in real-world applications.
// src/components/index.ts (barrel file)
export { Button } from './Button';
export { Modal } from './Modal'; // imports 'framer-motion' (60KB)
export { DataTable } from './DataTable'; // imports 'tanstack-table' (50KB)
export { Chart } from './Chart'; // imports 'recharts' (150KB)
export { CodeEditor } from './CodeEditor'; // imports 'monaco-editor' (2MB)
// Your page — only needs Button
import { Button } from '@/components';
In theory, tree shaking should eliminate Modal, DataTable, Chart, and CodeEditor. In practice, it often doesn't, because:
- Missing sideEffects declaration: Without
sideEffects: false, the bundler assumes every re-exported module might have side effects and includes all of them - CSS imports in the re-exported modules: If
Modal.tsximportsmodal.css, that's a side effect — the bundler keeps the module - Top-level function calls: If
Chart.tsxcallsregisterChartDefaults()at the top level, that's a side effect
The fix: Import directly from the source module, not the barrel.
// Direct import — tree shaking works reliably
import { Button } from '@/components/Button';
Pure Annotations: /*#__PURE__*/
Sometimes the bundler just can't tell if a function call has side effects. That's where /*#__PURE__*/ comes in — it's your way of telling the minifier "trust me, this call is side-effect-free, drop it if the result is unused."
// Without annotation — minifier keeps this because it might have side effects
const unused = createTheme({ primary: 'blue' });
// With annotation — minifier can remove it if 'unused' is never referenced
const unused = /*#__PURE__*/ createTheme({ primary: 'blue' });
React uses this extensively. React.createElement calls are annotated /*#__PURE__*/ so that unused component trees can be eliminated:
// Babel output for JSX
const element = /*#__PURE__*/ React.createElement("div", null, "Hello");
// If 'element' is never used, the entire createElement call is dropped
How Terser decides what's pure
Terser (the minifier used by Webpack and Next.js) uses these rules to determine if code can be removed:
- Variable assignments with no references — removed (dead code)
- Function calls — kept by default (might have side effects)
- Function calls with
/*#__PURE__*/— removed if result is unused - Property access — kept (might trigger getters with side effects)
- Module-level IIFEs — kept unless the entire module is unused and sideEffects: false
The key insight: Terser is conservative. It keeps anything that might have a side effect. Annotations are the escape hatch for when you know better than the static analyzer.
Practical Tree Shaking Audit
Alright, enough theory. Here's how you actually verify tree shaking works in your project:
Step 1: Create a test import
// test-tree-shaking.ts
import { oneSmallFunction } from './your-barrel-or-library';
console.log(oneSmallFunction());
Step 2: Build and measure
# Build with the test file as entry
# Then check the output size
ANALYZE=true npm run build
Step 3: Compare
If importing one function produces a bundle significantly larger than expected, tree shaking is failing. Check:
- Is the library ESM? (
"type": "module"or"module"field in package.json) - Does the library declare
sideEffects: false? - Are there barrel files in the import chain?
- Do any modules in the chain have top-level side effects?
# Check if a package is ESM
node -e "import('package-name').then(m => console.log('ESM:', Object.keys(m)))" 2>/dev/null || echo "Not ESM"
# Check sideEffects field
cat node_modules/package-name/package.json | grep -A 3 sideEffects
Framework-Specific Tree Shaking
Next.js
Good news if you're using Next.js: it tree-shakes server-only code from client bundles automatically. Code in Server Components, server actions, and getServerSideProps is excluded from client JavaScript.
// This function only runs on the server — excluded from client bundle
async function getProducts() {
const db = await import('./database'); // Never reaches the client
return db.query('SELECT * FROM products');
}
But 'use client' components include everything they import in the client bundle. A 'use client' component that imports a server utility accidentally ships that utility to the browser.
Rollup vs Webpack
Rollup was designed for library bundling with tree shaking as a primary feature. It's generally more aggressive at eliminating dead code. Webpack added tree shaking later and is more conservative — it relies more on Terser for final dead code elimination.
Turbopack (Next.js's development bundler) implements tree shaking at the module level, similar to Webpack, with improvements to handle barrel files more efficiently.
- 1Tree shaking requires ES modules — static imports, static exports, string literal specifiers. CommonJS cannot be tree-shaken.
- 2The bundler marks used exports starting from the entry point and removes everything unmarked. Terser then eliminates dead code paths.
- 3sideEffects: false in package.json tells the bundler it's safe to drop entire modules if their exports are unused. Incorrect declarations cause silent runtime bugs.
- 4Barrel files (index.ts re-exports) frequently defeat tree shaking. Import directly from source modules for reliable shaking.
- 5/*#__PURE__*/ annotations tell the minifier that a function call has no side effects and can be removed if the result is unused.
- 6Always verify tree shaking works: import one function, build, and check the bundle size. If it's unexpectedly large, trace the cause.
- 7Prefer ESM versions of libraries (lodash-es over lodash, date-fns over moment) for effective tree shaking.
Q: A colleague adds lodash as a dependency and imports { debounce } from it. The bundle grows by 72KB. Explain why and how to fix it.
A strong answer: lodash publishes CommonJS as its main entry point. CommonJS modules can't be tree-shaken because module.exports is a runtime object — the bundler can't statically determine which exports exist. The entire library (72KB) is included. Three fixes: (1) Use lodash-es which publishes ESM — tree shaking reduces the import to ~2KB for just debounce. (2) Import from the specific module path: import debounce from 'lodash/debounce' — each lodash function is also published as a separate file. (3) Replace with a custom implementation — debounce is ~15 lines of code and doesn't need a 72KB library.