Skip to content

Tree Shaking and Module Format

intermediate14 min read

Dead Code Has a Cost

You import one function from a utility library. Your bundle includes the entire library. Sound familiar?

This is the problem tree shaking solves. The term comes from the idea of shaking a tree — dead leaves (unused code) fall off, and only the living branches (used code) remain. It's one of the most impactful optimizations a bundler performs, and it's the reason your production bundle isn't 10x larger than it needs to be.

But tree shaking isn't magic, and it doesn't always work. Understanding why it works with certain code and why it fails with other code is the difference between shipping 50KB and shipping 500KB.

Mental Model

Think of tree shaking as a librarian organizing a reading list. You hand them a list of topics you need (your import statements). With ES modules, the library has a clear catalog — every book is listed by title, and the librarian can pull exactly what you need and leave the rest on the shelf. With CommonJS, the library has no catalog — books are in unlabeled boxes, and the librarian has to take the entire box because they can't tell which books are inside without opening it at runtime.

Why Tree Shaking Requires ES Modules

Tree shaking is fundamentally about static analysis — determining which exports are used without running any code. This only works with ES modules because:

// ESM — statically analyzable
import { map } from 'lodash-es';
// The bundler can see at parse time: only 'map' is used
// Everything else in lodash-es can be removed

// CJS — NOT statically analyzable
const { map } = require('lodash');
// This is a runtime destructure of a function call's return value
// The bundler can't know what 'require' returns without executing it

The core difference: import { map } is a declaration parsed before execution. require('lodash') is a function call evaluated at runtime. A bundler can analyze declarations; it can't predict function call results.

PropertyES ModulesCommonJS
Import syntaxStatic declaration — parsed before executionDynamic function call — evaluated at runtime
What's importedSpecific bindings are named at parse timeEntire module.exports object, destructured at runtime
Tree shakeableYes — bundler knows exactly which exports are usedNo — bundler must include entire module to be safe
Conditional importsNot possible with static import (use dynamic import())Possible — require() is just a function
Side effectsCan be marked as side-effect-free via sideEffects fieldAssumed to have side effects by default
Quiz
Why can't bundlers tree-shake CommonJS modules effectively?

How Tree Shaking Works Internally

Modern bundlers (Rollup, Vite, webpack 5, esbuild) follow a similar process:

The critical step is side effect analysis. Even if an export is unused, the module might have top-level code that runs when imported:

// utils.js
console.log('Module loaded!'); // This is a side effect

export function usedFunction() { return 42; }
export function unusedFunction() { return 0; }

If you only import usedFunction, the bundler could remove unusedFunction. But can it remove the console.log? That's a side effect that might be intentional. Without extra information, the bundler has to keep it — which means keeping the entire module.

The sideEffects Field — Your Tree Shaking Power Tool

The sideEffects field in package.json tells the bundler: "you can safely remove any unused imports from these files — they have no meaningful side effects."

{
  "name": "my-library",
  "sideEffects": false
}

"sideEffects": false means every file in the package is side-effect-free. The bundler can aggressively remove any unused exports without worrying about breaking something.

For packages that have some files with side effects:

{
  "sideEffects": [
    "*.css",
    "./src/polyfills.js",
    "./src/register-globals.js"
  ]
}

This says: "CSS files and these two specific JS files have side effects. Everything else is safe to tree-shake."

Common Trap

Forgetting "sideEffects": false in your library's package.json is one of the most common reasons for bloated bundles. Without it, bundlers assume every file might have side effects and are conservative about removing code. If you're publishing a utility library, this field is arguably more important than the code itself for your users' bundle sizes.

Quiz
What does sideEffects: false in package.json tell the bundler?

The Top Tree Shaking Killers

Killer 1: Barrel Files (Re-export Everything)

A barrel file is an index.js that re-exports everything from a directory:

// components/index.js — the barrel file
export { Button } from './Button';
export { Modal } from './Modal';
export { Tooltip } from './Tooltip';
export { DatePicker } from './DatePicker';
export { DataGrid } from './DataGrid';
// ... 50 more components
// Your code — you only need Button
import { Button } from './components';

In theory, tree shaking removes the unused components. In practice, it often fails because:

  1. Some components have side effects at the module level (CSS imports, global registrations)
  2. The barrel file itself becomes a large module that the bundler has to parse entirely
  3. Some bundlers bail out on large re-export chains
  4. sideEffects might not be configured correctly

The fix is direct imports:

// Instead of importing from the barrel
import { Button } from './components';

// Import directly from the source file
import { Button } from './components/Button';
Barrel files and dev server performance

Beyond tree shaking, barrel files hurt development performance too. When Vite encounters import { Button } from './components', it has to process every file that the barrel re-exports — even if you only need Button. This slows down HMR and initial page loads in development. The Vite team explicitly recommends avoiding deep barrel file chains.

Killer 2: CommonJS Dependencies

If your dependency ships only CommonJS, your bundler can't tree-shake it:

// lodash is CommonJS — entire library is included
import { chunk } from 'lodash';
// Bundle: ~70KB of lodash

// lodash-es is ESM — only chunk is included
import { chunk } from 'lodash-es';
// Bundle: ~2KB for chunk + dependencies

Check if your dependencies offer ESM versions (often published as separate packages with -es suffix or available via the module or exports field).

Killer 3: Dynamic Property Access

import * as utils from './utils';

// Static access — tree-shakeable
utils.formatDate(now);

// Dynamic access — NOT tree-shakeable
const methodName = getMethodName();
utils[methodName](); // Bundler can't know which method at build time

Killer 4: Re-exporting with Side Effects

// problem: this module is imported for its side effect (CSS)
// AND re-exports components
import './styles.css'; // side effect!
export { Button } from './Button';
export { Modal } from './Modal';

// Even if Button is unused, the bundler keeps this module
// because of the CSS import side effect

Killer 5: Class-Based Libraries

Class methods can't be individually tree-shaken:

// If a library exports a class:
export class Utils {
  static formatDate() { /* ... */ }
  static formatCurrency() { /* ... */ }
  static formatNumber() { /* ... */ }
}

// You can't tree-shake individual static methods.
// If you import Utils, you get all methods.

Function-based APIs are more tree-shakeable:

// Better — each function is a separate export
export function formatDate() { /* ... */ }
export function formatCurrency() { /* ... */ }
export function formatNumber() { /* ... */ }
Quiz
You import one function from a large barrel file (index.js that re-exports 50 components). In production, you notice all 50 components are in the bundle. What's the most likely cause?

Measuring Tree Shaking Effectiveness

Don't guess — measure. Here's how to verify tree shaking is working:

webpack Bundle Analyzer

// next.config.js (Next.js)
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});

// Run: ANALYZE=true npm run build

Vite / Rollup

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [
    visualizer({ open: true, gzipSize: true }),
  ],
};

Quick size check with bundlephobia

Before adding a dependency, check its bundle impact at bundlephobia.com. It shows the minified + gzipped size and whether tree shaking is supported.

Import cost in your editor

The "Import Cost" VS Code extension shows the size of each import inline. If you see a large number for a single named import, tree shaking might not be working.

Under the hood, tree shaking in Rollup (which powers Vite) works by building an AST (Abstract Syntax Tree) for each module, then performing a mark-and-sweep algorithm similar to garbage collection. Starting from entry points, it marks every referenced binding. Then it sweeps through the AST, removing any declarations that weren't marked. Side effects (function calls at the top level, property assignments on external objects) are conservatively kept unless sideEffects: false is specified. This is why pure functional code tree-shakes better than imperative code with side effects.

What developers doWhat they should do
Using CommonJS lodash instead of lodash-es
CommonJS modules can't be tree-shaken because require() is a runtime call. ESM's static imports enable the bundler to remove unused code.
Use the ESM version of libraries (lodash-es, date-fns, etc.)
Importing from barrel files when you only need one export
Barrel files force the bundler to process every re-exported module. Direct imports let the bundler skip unneeded modules entirely.
Import directly from the source file: import { Button } from './components/Button'
Forgetting sideEffects: false in your library's package.json
Without this field, bundlers conservatively keep all imported modules. With it, they can safely remove modules whose exports aren't used.
Add sideEffects: false (or list specific files with effects)
Key Rules
  1. 1Tree shaking requires ES modules — static imports are analyzable at build time, CommonJS require() is not
  2. 2sideEffects: false in package.json is critical — it tells bundlers to aggressively remove unused imports
  3. 3Barrel files (index.js re-exporting everything) are tree shaking killers — import directly from source files
  4. 4Function-based APIs tree-shake better than class-based APIs
  5. 5Always measure bundle size with a visualizer — don't assume tree shaking worked
  6. 6Dynamic property access (obj[computed]) defeats tree shaking because the bundler can't predict which properties are used