Tree Shaking: Eliminate Dead Code from Your Bundle

Easy

Tree shaking eliminates unused code automatically. Learn how to write tree-shakeable code and maximize bundle size reduction.

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 bundle

Build 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 unused

Avoid 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 dependencies

Real 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 analyzer

Best Practices

Do's

Prefer ES modules for app and library code
Use named exports where they make usage explicit
Import only what you need from libraries
Mark side-effect-free packages accurately in package.json
Use tree-shakeable alternatives when a package recommends them
Verify with bundle analyzer regularly

Don'ts

Avoid CommonJS for code you expect to prune
Do not import broad entry points when the package docs recommend direct imports
Do not use side-effectful barrels blindly
Do not assume a library choice is smaller without measuring

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() { /* ... */ }