Tooling

Tree Shaking: Eliminating Dead Code

A deep dive into tree shaking covering how static analysis eliminates unused code, sideEffects configuration, debugging failures, and library authoring for optimal tree shaking.

Tree Shaking: Eliminating Dead Code

Tree shaking is the process of eliminating unused exports from your JavaScript bundles during the build step. It relies on the static structure of ES module import and export statements to determine which pieces of code are actually consumed and which can be safely dropped. If you ship production bundles without tree shaking working correctly, you are shipping dead weight to your users and there is no good reason for it.

I have seen production bundles drop from 400KB to under 90KB simply by fixing tree shaking failures that nobody had noticed. This article covers the mechanics, the configuration, the debugging, and the library authoring practices that make tree shaking actually work.

Prerequisites

  • Working knowledge of JavaScript module systems (CommonJS and ES modules)
  • Familiarity with webpack or Rollup configuration
  • Node.js installed (v16+)
  • Basic understanding of bundling concepts

What Tree Shaking Actually Is

The term "tree shaking" comes from the mental model of shaking a dependency tree and letting the dead leaves fall off. In practice, it means your bundler performs static analysis on your import and export statements, builds a dependency graph, and marks any exported binding that is never imported anywhere as "unused." During the optimization pass (minification), those unused exports get removed entirely from the output.

This is different from generic dead code elimination (DCE). DCE removes code that is unreachable within a single file, like an if (false) block. Tree shaking removes code that is unreachable across module boundaries. The two work together: tree shaking marks an export as unused, and the minifier (Terser, typically) removes it as dead code.

The critical requirement is that your code uses ES module syntax. The import and export keywords are statically analyzable. The bundler can read them at build time without executing any code. CommonJS require() calls are dynamic, they can appear inside conditionals, be computed at runtime, and are fundamentally impossible to analyze statically with full accuracy. This is why tree shaking does not work with CommonJS.

How Static Analysis Works

When webpack or Rollup encounters your entry point, it begins building a module graph. For each file, it parses the import declarations to determine which bindings are consumed from each dependency.

Consider a utility module:

// utils/math.js (ES module syntax required for tree shaking)
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) {
  if (b === 0) throw new Error("Division by zero");
  return a / b;
}

And a consumer:

// app.js
import { add } from "./utils/math.js";

console.log(add(2, 3));

The bundler sees that app.js imports only add from math.js. The exports subtract, multiply, and divide are never imported anywhere in the entire application graph. They get marked as unused. When Terser runs, it strips them out entirely.

The key insight: this analysis is only possible because import { add } is a static declaration. The bundler does not need to execute your code to know which bindings are used.

Rollup: Where It Started

Rollup was the first mainstream JavaScript bundler to implement tree shaking, and it did so because ES modules were baked into its design from day one. Rich Harris built Rollup specifically around the static module graph that ES modules provide.

Rollup's tree shaking is still considered more aggressive and effective than webpack's in many cases. It defaults to tree shaking being on, with no special configuration needed. If you are authoring a library, Rollup is the better choice for producing a tree-shakeable output bundle.

// rollup.config.js
var resolve = require("@rollup/plugin-node-resolve");
var commonjs = require("@rollup/plugin-commonjs");
var terser = require("@rollup/plugin-terser");

module.exports = {
  input: "src/index.js",
  output: {
    file: "dist/bundle.js",
    format: "es"
  },
  plugins: [
    resolve(),
    commonjs(),
    terser()
  ]
};

Tree shaking is on by default. Rollup will warn you if it detects potential issues, such as circular dependencies that complicate the analysis.

Webpack Tree Shaking Configuration

Webpack added tree shaking support in version 2, but it requires specific configuration to work correctly. Here is the minimal setup:

// webpack.config.js
var path = require("path");
var TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist")
  },
  optimization: {
    usedExports: true,
    minimize: true,
    minimizer: [new TerserPlugin()],
    concatenateModules: true
  }
};

Three settings matter here:

  1. mode: "production" enables optimizations including tree shaking. In development mode, tree shaking analysis still runs (exports get marked), but the dead code is not actually removed so you can debug.

  2. usedExports: true tells webpack to determine which exports of each module are used and annotate them. This is the tree shaking analysis step. Webpack adds comments like /* unused harmony export multiply */ to the output, which Terser then uses to remove the dead code.

  3. concatenateModules: true enables scope hoisting (also called module concatenation). Instead of wrapping each module in a separate function, webpack merges modules into a single scope where possible. This makes it easier for Terser to identify and remove unused code, and it reduces function call overhead.

The sideEffects Field

This is where most tree shaking failures happen, and where most developers get confused.

Even if an export is unused, the module that contains it might have side effects: code that runs at the module level when the file is imported. This includes things like polyfills, CSS imports, global variable modifications, or prototype mutations.

If a module has side effects, the bundler cannot safely remove it even if none of its exports are used, because removing it would also remove the side effects that your application might depend on.

The sideEffects field in package.json tells the bundler which files are safe to skip entirely when none of their exports are used:

{
  "name": "my-utility-library",
  "version": "1.0.0",
  "sideEffects": false
}

Setting sideEffects: false tells the bundler: "Every file in this package is a pure module. If none of its exports are used, you can skip the entire file." This is a bold claim. You must verify it is accurate.

You can also be more granular:

{
  "name": "my-library",
  "version": "1.0.0",
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js",
    "./src/register-globals.js"
  ]
}

This tells the bundler that CSS files and the two listed JavaScript files have side effects and must always be included. Everything else is side-effect-free and can be dropped if unused.

Why This Matters So Much

Without sideEffects: false, webpack will keep every module that is even tangentially imported, regardless of whether any exports are used. Consider this:

// index.js (barrel file)
export { add } from "./add.js";
export { subtract } from "./subtract.js";
export { multiply } from "./multiply.js";
export { formatCurrency } from "./format.js";
export { validateEmail } from "./validate.js";
// consumer app
import { add } from "my-utility-library";

Without sideEffects: false, webpack sees that the barrel index.js imports from all five files. It cannot guarantee those files are side-effect-free, so it keeps them all. Your bundle includes subtract, multiply, formatCurrency, and validateEmail even though you only use add.

With sideEffects: false, webpack knows it can safely skip any module whose exports are entirely unused. Now only add.js and its direct dependencies make it into the bundle.

Pure Annotations

Sometimes the bundler cannot determine whether a function call has side effects. The /*#__PURE__*/ annotation tells the minifier that a specific call expression is pure and can be removed if its return value is unused:

var MyComponent = /*#__PURE__*/ createComponent({
  name: "MyComponent",
  render: function() { return null; }
});

export { MyComponent };

Without the /*#__PURE__*/ annotation, the minifier cannot safely remove the createComponent() call because it might have side effects (registering the component globally, for example). The annotation explicitly marks it as safe to drop.

This is especially important for library authors. Babel and TypeScript compilers insert these annotations when configured correctly. If you see them in your compiled output, your toolchain is doing the right thing.

Common Tree Shaking Failures

1. CommonJS Modules

This is the number one killer. If your dependency ships only CommonJS (using module.exports and require()), tree shaking will not work on it. The bundler must include the entire module.

// This CANNOT be tree-shaken
var _ = require("lodash");
_.map([1, 2, 3], function(n) { return n * 2; });
// This CAN be tree-shaken
import { map } from "lodash-es";
map([1, 2, 3], function(n) { return n * 2; });

The lodash package is CommonJS. The lodash-es package is the same code published as ES modules. Switching from lodash to lodash-es in one project I worked on reduced the lodash contribution to the bundle from 72KB to 4KB.

2. Barrel File Anti-Pattern

Barrel files (index.js files that re-export from many sub-modules) are convenient for developer experience but catastrophic for tree shaking when sideEffects is not configured:

// components/index.js — the barrel file
export { Button } from "./Button.js";
export { Modal } from "./Modal.js";
export { DatePicker } from "./DatePicker.js";
export { DataGrid } from "./DataGrid.js";
export { Chart } from "./Chart.js";

If the consuming code does import { Button } from "./components" and sideEffects is not set, the bundler may pull in all five component files. For large component libraries, this single pattern can add hundreds of kilobytes to your bundle.

The fix is either setting sideEffects: false or importing directly from the sub-module path:

import { Button } from "./components/Button.js";

3. Dynamic Imports and Computed Properties

Tree shaking requires static analysis. Anything dynamic breaks it:

// Tree shaking CANNOT analyze this
var moduleName = "math";
import("./utils/" + moduleName).then(function(mod) {
  mod.add(1, 2);
});
// This also defeats tree shaking
import * as utils from "./utils/math.js";
var fn = "add";
utils[fn](1, 2); // bundler cannot know which export is used

When you use import * and then access properties dynamically, the bundler must keep all exports because it cannot determine at build time which ones will be accessed.

4. Class Instantiation Side Effects

Classes with constructors that perform side effects are a subtle tree shaking failure:

export var registry = new Map();

export class Logger {
  constructor(name) {
    this.name = name;
    registry.set(name, this); // side effect: modifies external state
  }
}

Even if Logger is never imported elsewhere, the module-level registry variable and the class definition interact in ways that make the bundler conservative about removing them. Avoid module-level instantiation and mutable shared state if you want effective tree shaking.

Debugging Tree Shaking

When your bundle is larger than expected, you need to figure out why specific code is not being eliminated. Here are concrete debugging techniques.

Webpack Stats Output

Generate a stats file and analyze it:

npx webpack --profile --json > stats.json

Then use webpack-bundle-analyzer to visualize:

npx webpack-bundle-analyzer stats.json

This gives you a treemap showing exactly which modules contribute to your bundle size. Look for modules you do not expect to see.

Check for "unused harmony export" Comments

In development mode, webpack annotates unused exports but does not remove them. Build in development mode and search the output:

grep -r "unused harmony export" dist/

If you see an export marked as unused but the module is still fully included, the problem is usually missing sideEffects configuration.

Webpack Compilation Stats

Add stats logging to your webpack config:

// webpack.config.js
module.exports = {
  // ... other config
  stats: {
    usedExports: true,
    providedExports: true,
    optimizationBailout: true
  }
};

The optimizationBailout field is critical. It tells you exactly why webpack could not optimize a specific module. Common messages include:

  • "CommonJS bailout: module.exports is used directly" — the module uses CommonJS
  • "ModuleConcatenation bailout: Module is not an ECMAScript module" — cannot scope-hoist

The import Cost VS Code Extension

Install the import-cost extension to see the size cost of each import statement in real time. This gives you immediate feedback when you accidentally import something expensive.

Scope Hoisting and Its Role

Scope hoisting (webpack calls it "module concatenation") merges multiple modules into a single function scope instead of wrapping each in its own IIFE. This has two benefits:

  1. Smaller code: no wrapper function overhead per module
  2. Better tree shaking: Terser can see all the code in a single scope and more aggressively remove unused bindings

Enable it in webpack:

module.exports = {
  optimization: {
    concatenateModules: true
  }
};

In Rollup, scope hoisting is the default behavior. This is one reason Rollup tends to produce smaller bundles than webpack for library code.

Scope hoisting only works with ES modules. CommonJS modules cannot be concatenated.

CSS Tree Shaking with PurgeCSS

Tree shaking is not limited to JavaScript. PurgeCSS analyzes your HTML and JavaScript files to determine which CSS selectors are actually used, then removes the unused ones:

// postcss.config.js
var purgecss = require("@fullhuman/postcss-purgecss");

module.exports = {
  plugins: [
    purgecss({
      content: [
        "./src/**/*.html",
        "./src/**/*.js",
        "./src/**/*.jsx"
      ],
      defaultExtractor: function(content) {
        return content.match(/[\w-/:]+(?<!:)/g) || [];
      },
      safelist: {
        standard: [/^modal/, /^tooltip/],
        deep: [/^data-/]
      }
    })
  ]
};

In one project using Bootstrap, PurgeCSS reduced the CSS from 195KB to 12KB. Most utility CSS frameworks like Tailwind rely on PurgeCSS (now integrated as part of Tailwind's build) to keep production bundles small.

The safelist option is important for dynamic class names. If your JavaScript adds classes at runtime that do not appear in your source as string literals, PurgeCSS will remove them. Safelist those patterns explicitly.

Library Authoring for Tree Shaking

If you maintain a library, here is exactly what you need to do to make it tree-shakeable.

Use Named Exports, Not Default Exports

// BAD for tree shaking — the default export is a single object
export default {
  add: function(a, b) { return a + b; },
  subtract: function(a, b) { return a - b; }
};

// GOOD for tree shaking — each function is independently importable
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

Default exports of objects force the consumer to import the entire object. Named exports allow the bundler to include only the functions that are actually used.

Ship ES Module Builds

Your package.json should specify both CommonJS and ES module entry points:

{
  "name": "my-library",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  },
  "sideEffects": false
}

The module field is a de facto standard that bundlers (webpack, Rollup) use to prefer the ES module build. The exports field is the modern Node.js way to define conditional exports.

Avoid Barrel Files or Configure sideEffects Correctly

If you must use barrel files for API convenience, make absolutely sure sideEffects: false is set. Better yet, support direct deep imports:

{
  "exports": {
    ".": "./dist/esm/index.js",
    "./add": "./dist/esm/add.js",
    "./subtract": "./dist/esm/subtract.js"
  }
}

This lets consumers do import { add } from "my-library/add" and bypass the barrel entirely.

Complete Working Example

Let me walk through a full example that demonstrates tree shaking in action. We will create a small utility library and a consuming application, then compare bundle sizes with and without tree shaking working correctly.

The Utility Library

// src/utils/string.js
export function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

export function slugify(str) {
  return str.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
}

export function truncate(str, len) {
  if (str.length <= len) return str;
  return str.slice(0, len) + "...";
}

export function reverseString(str) {
  return str.split("").reverse().join("");
}

export function countWords(str) {
  return str.trim().split(/\s+/).length;
}
// src/utils/array.js
export function unique(arr) {
  var seen = {};
  return arr.filter(function(item) {
    if (seen[item]) return false;
    seen[item] = true;
    return true;
  });
}

export function flatten(arr) {
  var result = [];
  arr.forEach(function(item) {
    if (Array.isArray(item)) {
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  });
  return result;
}

export function chunk(arr, size) {
  var result = [];
  for (var i = 0; i < arr.length; i += size) {
    result.push(arr.slice(i, i + size));
  }
  return result;
}

export function groupBy(arr, key) {
  return arr.reduce(function(acc, item) {
    var group = item[key];
    if (!acc[group]) acc[group] = [];
    acc[group].push(item);
    return acc;
  }, {});
}
// src/utils/index.js (barrel file)
export { capitalize, slugify, truncate, reverseString, countWords } from "./string.js";
export { unique, flatten, chunk, groupBy } from "./array.js";

The Consuming Application

// src/app.js — only uses 2 of 9 available functions
import { capitalize, unique } from "./utils/index.js";

var names = ["alice", "bob", "alice", "charlie", "bob"];
var uniqueNames = unique(names);
var formatted = uniqueNames.map(function(name) {
  return capitalize(name);
});

console.log(formatted); // ["Alice", "Bob", "Charlie"]

package.json

{
  "name": "tree-shaking-demo",
  "version": "1.0.0",
  "sideEffects": false,
  "scripts": {
    "build": "webpack --mode production",
    "build:dev": "webpack --mode development",
    "build:analyze": "webpack --mode production --profile --json > stats.json && npx webpack-bundle-analyzer stats.json",
    "build:no-sideeffects": "webpack --mode production --env noSideEffects"
  },
  "devDependencies": {
    "terser-webpack-plugin": "^5.3.0",
    "webpack": "^5.88.0",
    "webpack-bundle-analyzer": "^4.9.0",
    "webpack-cli": "^5.1.0"
  }
}

Webpack Configuration

// webpack.config.js
var path = require("path");
var TerserPlugin = require("terser-webpack-plugin");

module.exports = function(env) {
  return {
    mode: "production",
    entry: "./src/app.js",
    output: {
      filename: "bundle.js",
      path: path.resolve(__dirname, "dist")
    },
    optimization: {
      usedExports: true,
      minimize: true,
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            compress: { dead_code: true, unused: true },
            mangle: false,
            output: { beautify: true }
          }
        })
      ],
      concatenateModules: true,
      sideEffects: env && env.noSideEffects ? false : true
    },
    stats: {
      usedExports: true,
      providedExports: true,
      optimizationBailout: true
    }
  };
};

Results: Before vs After

Running the build with sideEffects: true in package.json and the optimization recognizing it:

$ npm run build

asset bundle.js 482 bytes [emitted] [minimized] (name: main)
orphan modules 985 B [orphan] 2 modules
./src/app.js + 2 modules 1.31 KiB [built] [code generated]

  [exports: default]
  [all exports used]

  modules by path ./src/utils/ 985 B
    ./src/utils/string.js
      [exports: capitalize, countWords, reverseString, slugify, truncate]
      [only some exports used: capitalize]
    ./src/utils/array.js
      [exports: chunk, flatten, groupBy, unique]
      [only some exports used: unique]

With sideEffects: false and tree shaking working: 482 bytes.

Now disable sideEffects by removing it from package.json:

$ npm run build:no-sideeffects

asset bundle.js 1.89 KiB [emitted] [minimized] (name: main)

Without sideEffects optimization: 1.89 KiB — the full contents of both utility files are included.

That is a 4x size difference on a tiny example. In real applications with large dependency trees, the difference is measured in hundreds of kilobytes.

Verifying with Stats Output

The webpack stats show exactly which exports are used:

modules by path ./src/utils/ 985 B
  ./src/utils/string.js
    [exports: capitalize, countWords, reverseString, slugify, truncate]
    [only some exports used: capitalize]
  ./src/utils/array.js
    [exports: chunk, flatten, groupBy, unique]
    [only some exports used: unique]

This confirms that slugify, truncate, reverseString, countWords, flatten, chunk, and groupBy were all identified as unused and eliminated from the bundle.

Testing That Tree Shaking Works

You should verify tree shaking in your CI pipeline. Here is a practical approach:

// scripts/check-bundle-size.js
var fs = require("fs");
var path = require("path");

var bundlePath = path.resolve(__dirname, "../dist/bundle.js");
var stats = fs.statSync(bundlePath);
var sizeKB = stats.size / 1024;

var MAX_SIZE_KB = 100; // set your threshold

console.log("Bundle size: " + sizeKB.toFixed(2) + " KB");

if (sizeKB > MAX_SIZE_KB) {
  console.error("ERROR: Bundle exceeds " + MAX_SIZE_KB + "KB limit!");
  console.error("Current size: " + sizeKB.toFixed(2) + " KB");
  console.error("Run 'npm run build:analyze' to investigate.");
  process.exit(1);
}

console.log("Bundle size OK (under " + MAX_SIZE_KB + "KB limit)");

You can also search the minified output for strings that should not be present:

// scripts/verify-tree-shaking.js
var fs = require("fs");
var path = require("path");

var bundle = fs.readFileSync(
  path.resolve(__dirname, "../dist/bundle.js"),
  "utf-8"
);

var shouldNotExist = ["groupBy", "flatten", "reverseString", "slugify"];
var failures = [];

shouldNotExist.forEach(function(name) {
  if (bundle.indexOf(name) !== -1) {
    failures.push(name);
  }
});

if (failures.length > 0) {
  console.error("Tree shaking FAILED. Found unexpected exports in bundle:");
  console.error(failures.join(", "));
  process.exit(1);
}

console.log("Tree shaking verified: unused exports eliminated successfully.");

Run this in CI after your build step to catch tree shaking regressions immediately.

Common Issues & Troubleshooting

1. "Module is not an ECMAScript module" Bailout

Symptom: Webpack stats show ModuleConcatenation bailout: Module is not an ECMAScript module and scope hoisting is skipped.

Cause: The module uses CommonJS syntax (module.exports, require()) or a loader is transpiling ES modules to CommonJS before webpack processes them.

Fix: Check your Babel configuration. If you are using @babel/preset-env, set modules: false to preserve ES module syntax:

{
  "presets": [
    ["@babel/preset-env", { "modules": false }]
  ]
}

2. Entire Library Included Despite Using One Function

Symptom: Importing a single function from a large library but the entire library appears in the bundle analyzer output.

Cause: The library does not set sideEffects: false in its package.json, or it ships only CommonJS.

Fix: Check if an ES module variant exists (e.g., lodash-es instead of lodash). If not, import directly from the specific file path:

// Instead of this
import { debounce } from "some-library";

// Do this
import debounce from "some-library/dist/esm/debounce.js";

3. CSS Imports Breaking Tree Shaking

Symptom: JavaScript modules that import CSS files are kept in the bundle even though their JS exports are unused.

Cause: CSS imports are side effects by definition (they modify the page's appearance). Without telling webpack, it assumes any file with imports has side effects.

Fix: Mark CSS as a side effect explicitly while keeping JS files side-effect-free:

{
  "sideEffects": ["*.css", "*.scss", "*.less"]
}

4. Re-exports Through Barrel Files Not Being Eliminated

Symptom: You import one item from a barrel file but the bundle contains code from sibling modules that were also re-exported.

Cause: Missing sideEffects: false or using export * which makes it harder for the bundler to trace individual bindings.

Fix: Set sideEffects: false and prefer explicit named re-exports over wildcard re-exports:

// Prefer this
export { Button } from "./Button.js";
export { Modal } from "./Modal.js";

// Over this
export * from "./Button.js";
export * from "./Modal.js";

5. Webpack Development Mode Not Removing Code

Symptom: Dead code is still present in the output when building in development mode.

Cause: This is expected behavior. In development mode, webpack marks unused exports with comments but does not remove them, so you can see what would be eliminated without losing debuggability.

Fix: This is not a bug. Build in production mode (--mode production) to see actual tree shaking removal. Use the development mode annotations to verify that the analysis is correct.

Best Practices

  1. Always set sideEffects in package.json. For application code, audit your modules and set sideEffects: false or list specific files with side effects. For libraries, this is non-negotiable.

  2. Use named exports over default exports. Default exports of objects are opaque to tree shaking. Named exports give the bundler individual bindings to analyze.

  3. Prefer lodash-es over lodash and seek ES module variants of all dependencies. Check the module or exports field in a dependency's package.json. If neither exists, the package likely does not support tree shaking.

  4. Avoid barrel files in performance-critical paths. If you must use them, ensure sideEffects: false is configured. Better yet, support direct deep imports via the exports map in package.json.

  5. Keep Babel from transpiling ES modules to CommonJS. Set modules: false in @babel/preset-env. Let the bundler handle module resolution.

  6. Run bundle analysis regularly. Add webpack-bundle-analyzer to your build pipeline and review the output after adding new dependencies. Catch size regressions before they hit production.

  7. Use /*#__PURE__*/ annotations when authoring libraries. Mark factory functions and class instantiation calls as pure so the minifier can remove them when unused.

  8. Test bundle size in CI. Set a size budget and fail the build if the bundle exceeds it. This is the only reliable way to prevent gradual bundle bloat over time.

  9. Enable scope hoisting. Set optimization.concatenateModules: true in webpack. This improves tree shaking effectiveness and reduces per-module overhead.

References

Powered by Contentful