Tree Shaking: Eliminate Dead Code from Your Bundle
Tree shaking eliminates unused code automatically. Learn how to write tree-shakeable code and maximize bundle size reduction.
Quick Navigation: How Tree Shaking Works • Writing Tree-Shakeable Code • Library-Specific Patterns • Package Configuration • Verifying Tree Shaking • Best Practices
Quick Decision Guide
Quick Implementation Guide:
Use ES modules: Static import/export syntax is the most reliable shape for tree shaking. CommonJS and dynamic patterns are harder for bundlers to prove safe.
Named imports: import { specific } from 'library' can help, but package structure and side effects matter.
Side-effect-free code: Mark packages as side-effect-free only when unused files can be removed without changing behavior: "sideEffects": false.
Direct imports: For libraries with heavy entry points, use ESM or direct import paths when recommended by the package.
Verify results: Use a bundle analyzer to see what is actually included.
How Tree Shaking Works
Static Analysis
Tree shaking works best with ES module syntax because static imports and exports let bundlers reason about the module graph:
// Static imports (analyzable at build time)
import { functionA, functionB } from './utils';
// Only functionA is used
functionA();
// Result: functionB is removed from bundleBuild Process
1. Bundler analyzes imports: Tracks what's imported and used
2. Marks unused exports: Identifies dead code
3. Removes dead code: Excludes from final bundle
4. Minification: Further optimization
Example
// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }
// app.js
import { add, multiply } from './utils';
console.log(add(2, 3));
// Final bundle only includes:
// - add function
// - multiply is imported but unused (removed)
// - subtract and divide are never imported (removed)Writing Tree-Shakeable Code
Use ES6 Modules
// ✅ Tree-shakeable (ES6)
export function debounce() { /* ... */ }
export function throttle() { /* ... */ }
// ⚠️ Harder to tree shake reliably (CommonJS)
module.exports = {
debounce: function() { /* ... */ },
throttle: function() { /* ... */ }
};Named Exports vs Default
// ✅ Usually better: Named exports
export function getUser() { /* ... */ }
export function deleteUser() { /* ... */ }
import { getUser } from './api';
// Only getUser is bundled
// ⚠️ Less optimal: Default export
export default {
getUser: () => { /* ... */ },
deleteUser: () => { /* ... */ }
};
import api from './api';
api.getUser();
// Entire object is bundled even if deleteUser is unusedAvoid Side Effects
// ❌ Has side effects (can't be tree-shaken)
import './styles.css'; // Modifies global state
console.log('Module loaded'); // Runs on import
export function myFunction() { /* ... */ }
// ✅ No side effects (tree-shakeable)
export function myFunction() { /* ... */ }Library-Specific Patterns
Lodash
// ⚠️ Broad entry point may include more code than needed
import _ from 'lodash';
_.debounce(fn, 300);
// ⚠️ Okay in modern bundlers, but not ideal
import { debounce } from 'lodash';
// May still include extra shared code depending on bundler
// ✅ Often better: Use an ESM build when the package recommends it
import { debounce } from 'lodash-es';
// ✅ Also useful: Direct import for explicit bundle control
import debounce from 'lodash-es/debounce';Material-UI / MUI
// ✅ Good: Named imports (recommended for most apps)
// Modern bundlers (Webpack 5, Vite, etc.) tree-shake this correctly
import { Button, TextField } from '@mui/material';
// ✅ Also good: Direct imports (maximum control / safety)
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
// ⚠️ Note: In MUI v5+, both approaches are tree-shakeable.
// Direct imports may still slightly reduce bundle size in edge cases.Date Libraries
// ⚠️ Moment.js is often large for modern apps; verify before choosing it
import moment from 'moment';
// ✅ Often a better fit: date-fns has modular ESM-friendly APIs
import { format, addDays } from 'date-fns';
// ✅ Also useful: Day.js is small and Moment-like
import dayjs from 'dayjs';Package Configuration
package.json - sideEffects
Tell bundlers which files have side effects:
{
"name": "my-library",
"sideEffects": false
}sideEffects: false = "Unused files may be removed." Only use it when importing a file does not perform required work such as loading CSS, registering globals, or patching prototypes.
Specific Side Effects
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js"
]
}Only these files have side effects, everything else is tree-shakeable.
Module Fields
{
"main": "dist/index.cjs.js", // CommonJS entry, harder to statically prune
"module": "dist/index.esm.js", // ESM entry, easier to analyze
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
}
}Modern bundlers prefer the "module" or "exports.import" field.
Verifying Tree Shaking
Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
};Vite Bundle Analysis
npm run build -- --mode analyze// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
})
]
};Check Import Size
// Before optimization
import _ from 'lodash'; // inspect actual bundled size
// After optimization
import { debounce } from 'lodash-es'; // inspect actual bundled size
// Savings depend on bundler, package version, and shared dependenciesReal Example
Before tree shaking:
bundle.js: 850KB
- broad utility/date imports
- unused feature code
- side-effect-free modules the bundler can prove unused
After tree shaking:
bundle.js: smaller after unused code and heavy imports are removed
- targeted utility/date imports
- unused code removed where safe
- remaining shared dependencies still measured in analyzerBest Practices
Do's
Don'ts
Common Mistakes
1. Mixing Module Systems
// ⚠️ Mixed module systems can reduce tree-shaking effectiveness
import { func1 } from './module1'; // ES6
const func2 = require('./module2'); // CommonJS
// ✅ Consistent ES6
import { func1 } from './module1';
import { func2 } from './module2';2. Re-exporting Everything
// ⚠️ Barrel export can pull in more than expected if modules have side effects
// index.js
export * from './module1';
export * from './module2';
// ✅ Named re-exports
export { specific, functions } from './module1';3. Side Effects in Modules
// ❌ Side effect on import
// utils.js
console.log('Utils loaded');
export function add() { /* ... */ }
// ✅ No side effects
export function add() { /* ... */ }