JavaScript Tree Shaking: A Complete Tutorial

A complete tutorial on JavaScript tree shaking. Covers how bundlers eliminate dead code, ES module static analysis, side effect declarations, tree-shakable library design, common pitfalls, barrel file issues, and configuring webpack and Rollup for optimal tree shaking.

JavaScriptadvanced
16 min read

Tree shaking eliminates unused code from your JavaScript bundles by analyzing ES module imports and removing exports that no consumer references. This tutorial covers how tree shaking works, how to write tree-shakable code, and how to configure bundlers for maximum dead code removal.

For removing dead code patterns, see Removing Dead Code with JS Tree Shaking Guide.

How Tree Shaking Works

javascriptjavascript
// math.js - ES module with named exports
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;
}
 
// app.js - only imports add
import { add } from "./math.js";
console.log(add(2, 3));
 
// After tree shaking, the bundle contains ONLY:
// function add(a, b) { return a + b; }
// console.log(add(2, 3));
// subtract, multiply, divide are eliminated
ConceptDescription
Static analysisBundler reads import/export at build time, not runtime
Used exportsExports referenced by at least one import statement
Dead exportsExports never imported anywhere in the dependency graph
Side effectsCode that runs on import (modifies globals, DOM, etc.)
Pure annotationsHints telling the bundler a call has no side effects

ES Modules vs CommonJS

javascriptjavascript
// ES Modules: TREE-SHAKABLE (static structure)
// Imports/exports are determined at parse time
import { specific } from "./lib"; // Bundler knows exactly what is used
 
// CommonJS: NOT TREE-SHAKABLE (dynamic structure)
// Imports/exports are determined at runtime
const lib = require("./lib"); // Bundler cannot know which props are used
const specific = lib.specific; // This pattern is opaque to static analysis
 
// Dynamic import patterns that BREAK tree shaking:
const name = condition ? "add" : "subtract";
import("./math").then((mod) => mod[name]); // Dynamic key, cannot tree shake
 
// Destructured require: PARTIALLY works with some bundlers
const { specific } = require("./lib"); // Webpack can sometimes handle this
 
// ==========================================
// MODULE AUTHORING FOR TREE SHAKING
// ==========================================
 
// BAD: default export with object (not tree-shakable)
export default {
  add(a, b) { return a + b; },
  subtract(a, b) { return a - b; },
};
// Consumer: import math from "./math"; math.add(1,2);
// Bundler includes the entire object
 
// GOOD: named exports (fully tree-shakable)
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// Consumer: import { add } from "./math";
// Bundler includes only add

Side Effects Configuration

jsonjson
// package.json - declare your package as side-effect free
{
  "name": "my-library",
  "version": "1.0.0",
  "sideEffects": false
}
 
// Specific files with side effects
{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js",
    "./src/global-setup.js"
  ]
}
javascriptjavascript
// What counts as a side effect?
 
// SIDE EFFECT: modifies global state on import
window.myLib = {};
document.title = "Modified";
 
// SIDE EFFECT: adds to prototype
Array.prototype.customMethod = function() {};
 
// SIDE EFFECT: executes code with observable effects
console.log("Module loaded");
fetch("/api/init");
 
// NOT a side effect: only declares/exports
export function helper() { return 42; }
export const CONSTANT = "value";
export class Widget {}
 
// PURE annotation: tell bundler this call is safe to remove
const instance = /* #__PURE__ */ createInstance();
export { instance };
 
// Without #__PURE__, bundler assumes createInstance() has side effects
// and keeps it even if instance is never imported

Tree-Shakable Library Design

javascriptjavascript
// BAD PATTERN: barrel file re-exporting everything
// index.js
export { Chart } from "./Chart";
export { Table } from "./Table";
export { Form } from "./Form";
export { Modal } from "./Modal";
export { Tooltip } from "./Tooltip";
// Problem: some bundlers struggle with barrel re-exports
// importing { Chart } might pull in Table, Form, Modal, Tooltip too
 
// GOOD PATTERN: direct deep imports
// Allow consumers to import directly
// import { Chart } from "my-lib/Chart";
 
// GOOD PATTERN: package.json "exports" field
{
  "name": "my-lib",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./Chart": {
      "import": "./dist/esm/Chart.js",
      "require": "./dist/cjs/Chart.js"
    },
    "./Table": {
      "import": "./dist/esm/Table.js",
      "require": "./dist/cjs/Table.js"
    }
  },
  "sideEffects": false
}
javascriptjavascript
// TREE-SHAKABLE CLASS PATTERN
 
// BAD: class with many methods (all or nothing)
export class Utils {
  static formatDate(d) { /* ... */ }
  static formatCurrency(n) { /* ... */ }
  static slugify(s) { /* ... */ }
  static truncate(s, len) { /* ... */ }
}
// import { Utils } from "./utils";
// Utils.formatDate(new Date());
// All methods included even if only formatDate is used
 
// GOOD: individual function exports
export function formatDate(d) {
  return d.toISOString().split("T")[0];
}
 
export function formatCurrency(n) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
  }).format(n);
}
 
export function slugify(s) {
  return s.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
}
 
export function truncate(s, len) {
  return s.length > len ? s.slice(0, len) + "..." : s;
}
// import { formatDate } from "./utils";
// Only formatDate is bundled

Webpack Tree Shaking Configuration

javascriptjavascript
// webpack.config.js
module.exports = {
  mode: "production", // Required for tree shaking
 
  optimization: {
    usedExports: true,     // Mark unused exports
    minimize: true,        // Remove marked dead code
    concatenateModules: true, // Scope hoisting for better shaking
 
    sideEffects: true,     // Respect package.json sideEffects
 
    innerGraph: true,      // Track usage within modules
 
    splitChunks: {
      chunks: "all",
    },
  },
 
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [
              ["@babel/preset-env", {
                modules: false, // CRITICAL: do not convert ES modules to CJS
              }],
            ],
          },
        },
      },
    ],
  },
};
 
// Rollup config (tree shaking is on by default)
// rollup.config.js
export default {
  input: "src/index.js",
  output: {
    file: "dist/bundle.js",
    format: "esm",
  },
  treeshake: {
    moduleSideEffects: false, // Assume no module has side effects
    propertyReadSideEffects: false, // Property access has no side effects
    annotations: true, // Respect #__PURE__ annotations
  },
};

Analyzing Tree Shaking Results

javascriptjavascript
// Check what was tree-shaken with webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin =
  require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
 
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: "static",
      reportFilename: "bundle-report.html",
      openAnalyzer: false,
    }),
  ],
};
 
// Custom script to check tree-shakability
function analyzeExports(sourceCode) {
  const exportPattern = /export\s+(function|const|let|var|class)\s+(\w+)/g;
  const exports = [];
  let match;
 
  while ((match = exportPattern.exec(sourceCode)) !== null) {
    exports.push({ type: match[1], name: match[2] });
  }
 
  const defaultExport = /export\s+default/.test(sourceCode);
 
  return {
    namedExports: exports,
    hasDefaultExport: defaultExport,
    treeShakable: exports.length > 0 && !defaultExport,
    recommendation: defaultExport
      ? "Convert default export to named exports for better tree shaking"
      : "Module uses named exports (tree-shakable)",
  };
}
BundlerTree Shaking SupportConfiguration Required
Webpack 5Yes (production mode)usedExports: true, sideEffects: true
RollupYes (default)Minimal, enabled by default
Vite (Rollup)Yes (default)Inherits Rollup defaults
esbuildYes (default)--tree-shaking=true (default in bundle mode)
Parcel 2Yes (default)Automatic, no config needed
Rune AI

Rune AI

Key Insights

  • Named exports are essential for tree shaking: Default exports with object literals prevent bundlers from identifying which properties are used
  • sideEffects: false unlocks full tree shaking: Without this package.json flag, bundlers conservatively keep all modules imported anywhere in the dependency graph
  • Babel must preserve ES modules: Configure modules: false in @babel/preset-env to prevent converting ES module syntax to CommonJS before the bundler can analyze it
  • Barrel files can defeat tree shaking: Direct deep imports (my-lib/Chart) are more reliably tree-shaken than barrel re-exports (my-lib then { Chart })
  • Use #PURE annotations for function calls: Mark factory function calls as pure so bundlers can safely remove them when the result is unused
RunePowered by Rune AI

Frequently Asked Questions

Why does tree shaking not work with CommonJS require()?

Tree shaking depends on static analysis of import/export statements at build time. CommonJS `require()` is a runtime function that can be called conditionally, with dynamic paths, or inside functions, making it impossible for bundlers to determine at build time which exports are used. ES modules have a static structure where imports must be at the top level with string literals.

What is the role of sideEffects in package.json?

The `sideEffects` field tells bundlers which files can be safely removed if their exports are unused. Setting `"sideEffects": false` means no file in the package runs code on import that affects global state. Without this flag, bundlers must assume every module has side effects and cannot remove unused re-exports from barrel files.

Why do barrel files (index.js re-exports) sometimes break tree shaking?

Barrel files that re-export from many modules create a chain where the bundler must evaluate each source module to determine if it has side effects. If any module in the chain has side effects (or lacks `sideEffects: false`), the bundler conservatively includes it. Some bundlers handle barrel files better than others, but direct deep imports always tree shake reliably.

How do I know if my library is tree-shakable?

Check three things: the library ships ES module builds (check the `module` or `exports` field in package.json), the package.json has `"sideEffects": false`, and the library uses named exports instead of a single default export. Use `webpack-bundle-analyzer` or `rollup-plugin-visualizer` to verify unused code is actually removed.

Does TypeScript affect tree shaking?

TypeScript itself does not prevent tree shaking as long as the compiler outputs ES modules (`"module": "esnext"` or `"module": "es2020"` in tsconfig.json). If TypeScript compiles to CommonJS (`"module": "commonjs"`), tree shaking will not work. Enum values and namespace patterns can sometimes prevent tree shaking when compiled.

Conclusion

Tree shaking relies on ES modules' static structure to identify and remove dead code at build time. Writing tree-shakable code means using named exports, declaring sideEffects: false, and avoiding patterns that obscure the dependency graph. For advanced dead code removal techniques, see Removing Dead Code with JS Tree Shaking Guide. For bundler architecture, see JavaScript Bundlers: An Advanced Architecture.