JavaScript Tree Shaking: Halve Your Bundle Size by Removing Dead Code
AI generated
JS
() =>
JavaScript · Tree Shaking · Performance · Bundle Optimization
JavaScript Tree Shaking
Halving Bundle Size Through Dead Code Elimination

Tree shaking is the process by which modern bundlers remove unused JavaScript code from the final bundle. What sounds simple has complex prerequisites: ES modules are mandatory, certain code patterns block tree shaking, and misconfigured sideEffects flags can eliminate or preserve entire modules, with measurable effects on load times.

13 min read ESM · sideEffects · Rollup · Webpack · Vite · bundle analysis Dead Code Elimination · Scope Hoisting

1. What tree shaking is and why it matters

Tree shaking is the technique by which JavaScript bundlers remove unused exports and their dependencies from the final bundle. The name comes from the image of shaking a tree: whatever is not attached falls off. In practice this means: if an application only imports format from a utility library with a hundred functions, the final bundle contains only format and its dependencies, not the other 99 functions. This reduces bundle size directly in proportion to the share of unused code.

The impact on load times is significant. Every kilobyte of JavaScript means parse time, compile time and transfer time, especially on mobile devices and weak networks. Libraries like lodash weigh several hundred kilobytes in their CJS version; with correct tree shaking, only the code actually used ends up in the bundle. This is not an academic detail: studies show that every second of load time reduces the conversion rate by several percentage points. Tree shaking is therefore not just a technical optimization, but directly relevant to the business.

2. Why ES modules are mandatory: static import analysis

At the core of tree shaking is the static analysis of import and export relationships. Only if a bundler can determine at build time, without executing code, which exports are actually used, can it safely decide which parts to remove. ES modules make this possible: import and export are static constructs, they always sit at the top level, cannot be conditional, and must have static string literals as paths. This lets the bundler construct the full import-export graph before a single line runs.

CommonJS modules (require() and module.exports) are dynamic by contrast: require() can be called at runtime with a computed path, and exports can be added at runtime as properties of module.exports. This makes static analysis impossible. A bundler that encounters a CJS module must include the entire module in the bundle; it cannot know which exports are used in a foreign runtime context. Tree shaking therefore works exclusively with ESM, and this is not a design choice made by bundler authors, but a mathematical necessity arising from the dynamism of CJS.


// ESM: static, bundler can analyze at build time WITHOUT executing code
import { formatDate, parseDate } from "./date-utils.js";
// Bundler knows: only formatDate and parseDate are used
// → everything else in date-utils.js is candidate for tree-shaking

// CJS: dynamic, bundler cannot know what will be used at runtime
const utils = require("./date-utils"); // entire module included
utils.formatDate(new Date());
// OR: computed require, impossible to analyze statically
const fnName = "format" + "Date";
const fn = require("./date-utils")[fnName]; // bundler gives up

// ESM: conditional imports are forbidden, enables static graph
// This is INVALID in ESM (syntax error):
// if (condition) { import { x } from "./mod.js"; }

// Valid ESM alternative: dynamic import(), but affects tree-shaking
const { x } = await import(condition ? "./a.js" : "./b.js");
// Bundler includes both ./a.js and ./b.js when condition is dynamic

3. The sideEffects flag in package.json

The sideEffects field in a library's package.json is the most important lever for tree shaking of external dependencies. Without this field the bundler must stay conservative: an imported module could have side effects on import (CSS injection, global registrations, prototype extensions) and must therefore not be removed, even if none of the module's exports are used. With "sideEffects": false the library signals: no module has side effects on import, the bundler may safely remove all unused modules.

"sideEffects": ["*.css", "polyfills.js"] is the differentiated pattern: most files have no side effects, but CSS imports and the polyfill file must be preserved. This is critical for component libraries such as Material UI or Ant Design: they import CSS as a side effect, and without an explicit exception in the sideEffects array, tree shaking would remove those CSS imports and break the styling. As a library author, the sideEffects field is mandatory; without it you deliver your users worse tree shaking than necessary.

4. Code patterns that prevent tree shaking

Various JavaScript patterns are opaque to bundlers during static analysis and prevent effective tree shaking. The most common one: object exports where all functions are bundled as properties of a single export. export default { formatDate, parseDate, ... } exports an object, the bundler cannot know which properties of the object are used and must keep the entire object. Named exports (export function formatDate()) allow precise tree shaking at the function level instead.

Another critical pattern: IIFE-wrapped modules and classes with static methods. Because classes in JavaScript are mutable and static methods can be added at runtime, bundlers treat classes conservatively: they keep the entire class even if only one static method is used. The counter-pattern: standalone functions as named exports instead of static methods. Anyone writing a utility library should deliberately avoid class-based APIs when tree shaking matters.


// date-utils.js: tree-shaking friendly library design

// BAD: default export object, bundler cannot tree-shake individual functions
export default {
  formatDate: (d) => d.toISOString(),
  parseDate: (s) => new Date(s),
  diffDays: (a, b) => Math.abs(a - b) / 86400000,
  // 97 more functions, ALL included even if you only use formatDate
};

// GOOD: named exports, each function is individually tree-shakeable
export function formatDate(date) {
  return date.toISOString();
}

export function parseDate(str) {
  return new Date(str);
}

export function diffDays(dateA, dateB) {
  return Math.abs(dateA - dateB) / 86400000;
}

// Consumer only imports what they need
import { formatDate } from "./date-utils.js";
// parseDate and diffDays are excluded from the bundle

// BAD: class with static methods, entire class is retained
export class DateUtils {
  static formatDate(d) { return d.toISOString(); }
  static parseDate(s) { return new Date(s); }
}
// import { DateUtils } from "./date-utils" then DateUtils.formatDate()
// → entire class bundle, even if only formatDate is used

// Pure annotation, hint to bundler that function has no side effects
export const expensiveCalc = /*#__PURE__*/ computeConstants();
// Without /*#__PURE__*/, bundler assumes function call has side effects

5. Writing tree-shaking-friendly libraries

Writing a tree-shaking-friendly library requires deliberate decisions on several levels. First: only use named exports, never default export objects. Second: set "sideEffects": false in package.json, unless specific files genuinely have side effects. Third: declare the ESM build as the module entry point in package.json, alongside the CJS build under main. Bundlers prefer the module entry point for tree shaking.

The fourth important point: no barrel files (index.js) with re-exports from many submodules, if each submodule is free of side effects. Barrel files that re-export hundreds of exports make analysis hard for the bundler and often lead to more code than necessary being included. Instead: enable deep imports (import { formatDate } from "date-utils/date"), or explicitly mark the barrel file as "sideEffects": false and rely on correct ESM structure so the bundler can resolve the re-export chain.

6. Configuring tree shaking with Webpack and Rollup

Rollup was the first bundler with native tree shaking support and remains the standard for library builds to this day. Rollup's tree shaking is particularly aggressive: it analyzes the full dependency graph and removes anything that does not contribute to the output. For application bundles with Webpack: tree shaking is automatically active in production mode through the combination of mode: "production" (enables TerserPlugin for dead code elimination), optimization.usedExports: true (marks unused exports) and optimization.sideEffects: true (reads the sideEffects flag from package.json).

Important: Webpack's tree shaking works in two stages. First it marks unused exports with a comment (/* unused harmony export */). In a second step, TerserPlugin actually removes these marked exports from the minified output. Without TerserPlugin (i.e. in development mode) unused exports are present in the bundle, but marked, visible in the unminified build. This matters for debugging: anyone who wants to debug tree shaking must build in production mode or enable Terser explicitly.

7. Tree shaking: ESM vs. CJS vs. IIFE compared

The choice of module format has a direct impact on the effectiveness of tree shaking. ESM enables full tree shaking, since the bundler can statically analyze the entire import-export graph. CJS is dynamic and enables no tree shaking at the module level, the bundler must include the whole module. IIFE bundles are immutable, no bundler can perform further tree shaking on an already-bundled IIFE output.

Format Tree shaking possible? Typical use Bundler support
ESM (.mjs, type:module) Fully All modern apps and libraries Rollup, Webpack 5, Vite, esbuild
CJS (.cjs, require) Not possible Legacy Node.js, old libraries No bundler can shake CJS
UMD Not possible Browser + Node compatible, outdated CJS path dominates, no shaking
IIFE Not possible Script-tag inclusion, CDN Already bundled, not analyzable
ESM + CJS dual Via module field Modern libraries with backward compatibility Bundlers use the module field for ESM

For library authors, the dual-publish pattern is the recommendation: ESM build under exports["."].import and module, CJS build under exports["."].require and main. Bundlers automatically prefer the ESM build, enabling tree shaking. Node.js without a bundler uses the CJS build for backward compatibility. With the newer exports field in package.json (conditional exports) this is cleanly configurable.

8. Bundle analysis: making dead code visible with tools

Without analysis tools it is impossible to know whether tree shaking is actually taking effect and what is sitting inside the bundle. The most important tool for Webpack projects is webpack-bundle-analyzer: it creates an interactive treemap showing which modules take up how much space in the bundle. Large, unexpected blocks immediately show where tree shaking is not working or where libraries without tree-shaking support are being bundled in. For Vite projects, rollup-plugin-visualizer offers the same functionality.

A second important tool is bundle-buddy or Webpack's built-in stats.json output, which shows which modules come from which files and how much code from each dependency actually ends up in the bundle. For quick checks without build integration: bundlephobia.com shows for every npm library whether it supports tree shaking and how large the minimal import of a single function is. That is the first check to run before adding a new dependency to a project.


// webpack.config.js: full tree shaking configuration
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");

module.exports = {
  mode: "production", // enables tree shaking + terser minification
  optimization: {
    usedExports: true,    // marks unused exports for removal
    sideEffects: true,    // respects package.json "sideEffects" field
    concatenateModules: true, // scope hoisting, reduces module overhead
    minimize: true,       // terser removes marked unused exports
  },
  plugins: [
    // Generate interactive bundle visualization
    new BundleAnalyzerPlugin({
      analyzerMode: "static", // creates report.html, no server needed
      openAnalyzer: false,
    }),
  ],
};

// package.json of a tree-shaking-friendly library
// {
//   "name": "my-utils",
//   "main": "./dist/index.cjs",        // CJS for Node.js require()
//   "module": "./dist/index.mjs",      // ESM for bundlers (tree shaking)
//   "exports": {
//     ".": {
//       "import": "./dist/index.mjs",  // ESM, preferred by bundlers
//       "require": "./dist/index.cjs"  // CJS, for legacy Node.js
//     }
//   },
//   "sideEffects": false               // no modules have import side effects
// }

// Mark a specific function call as pure (no side effects)
// so bundler can remove it if result is unused
const VERSION = /*#__PURE__*/ computeVersion();

9. Scope hoisting: the partner of tree shaking

Scope hoisting, known in Webpack as module concatenation, is the natural companion to tree shaking. Without scope hoisting, a bundler wraps every module in its own function to isolate scope. This creates significant overhead: thousands of small wrapper functions in the bundle, each with its own scope setup. With scope hoisting, the bundler merges modules that are statically linked and have no circular dependencies into a single scope: fewer functions, less overhead, a smaller bundle, faster execution.

Scope hoisting, like tree shaking, requires ESM. CJS modules must remain wrapped separately, because their dynamic nature makes static fusion impossible. In Webpack, scope hoisting is active with optimization.concatenateModules: true (the default in production mode). Rollup always performs scope hoisting, it is a fundamental design goal of Rollup, not an optional optimization. This is one of the main reasons Rollup bundles are so efficient for libraries: no module wrapper overhead, no duplicated scope setup code.

10. Summary

Tree shaking is one of the most effective performance optimizations for JavaScript applications, but only if the prerequisites are met. ES modules are strictly necessary, since only they enable the static import-export analysis that bundlers need for tree shaking. The sideEffects field in package.json is mandatory for library authors; without it the bundler cautiously prevents modules from being removed. Named exports instead of default export objects, no classes with static methods, and the /*#__PURE__*/ comment for side-effect-free function calls are the code patterns that enable tree shaking at the function level.

With bundle analysis tools such as webpack-bundle-analyzer and rollup-plugin-visualizer you can measure whether tree shaking is actually taking effect and which libraries bring unused code into the bundle. The result of consistent tree shaking is measurable: bundles that contain only code that is actually used load faster, parse faster and execute faster, especially on mobile devices, where these differences directly relate to user experience and conversion rate.

Mironsoft

JavaScript performance, bundle optimization and build pipeline modernization

Bundle too big? We analyze and optimize.

We analyze your build output, identify libraries without tree-shaking support and optimize the build configuration for maximum dead code elimination.

Bundle Analysis

Complete analysis of the build output: identifying dead code, tracking down libraries without tree shaking

Build Optimization

Webpack/Vite configuration for maximum tree shaking, scope hoisting and code splitting

Library Design

Tree-shaking-friendly API design, sideEffects configuration and dual-publish setup

JavaScript Tree Shaking: The Essentials at a Glance

ESM is mandatory

Tree shaking only works with ES modules. CJS (require) and IIFE do not allow static import analysis, bundlers must include everything.

sideEffects flag

"sideEffects": false in package.json signals: all modules safely removable. Array for exceptions (CSS, polyfills). Mandatory for every library.

Prefer named exports

export function foo() instead of export default { foo }. Classes with static methods block tree shaking. /*#__PURE__*/ for side-effect-free calls.

Analysis tools

webpack-bundle-analyzer: treemap of bundle contents. rollup-plugin-visualizer for Vite. bundlephobia.com: check tree-shaking support before npm install.

11. FAQ: JavaScript Tree Shaking and Bundle Size

1What is tree shaking?
Bundler technique for removing unused exports and their dependencies from the final bundle. Only code that is actually used ends up in the output, resulting in measurably smaller bundles.
2Why not with CommonJS?
require() is dynamic, bundlers cannot statically analyze CJS and must include everything. Only ESM with static import/export allows tree shaking.
3What does sideEffects: false mean?
No module has side effects on import, the bundler can safely remove all unused modules. Without this flag it must proceed conservatively and include more.
4Which patterns block tree shaking?
Default export objects, classes with static methods, dynamic property access, require() with computed paths. Named exports and standalone functions enable shaking.
5Enable Webpack tree shaking?
mode: 'production' enables it automatically. Explicitly: usedExports: true, sideEffects: true, minimize: true with Terser. Development mode only marks, it does not remove.
6Tree shaking vs. scope hoisting?
Tree shaking removes unused code. Scope hoisting merges modules together and eliminates wrapper overhead. Both require ESM, complement each other and are active in production mode.
7How to check if tree shaking works?
webpack-bundle-analyzer: interactive treemap. Search the Webpack output for '/* unused harmony export */'. bundlephobia.com before npm install for tree-shaking support.
8What is /*#__PURE__*/ for?
A hint to the bundler: this function call has no side effects and can be removed if the result is not used. Useful for module-level computations and factory calls.
9How should a library be structured?
Named exports, sideEffects: false, ESM under the "module" field, CJS under "main". Conditional exports with the exports field for precise path control and backward compatibility.
10Vite without configuration?
Yes, Vite uses Rollup for production builds with native tree shaking and scope hoisting. Prerequisite: dependencies with an ESM build and a correctly set sideEffects flag.