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.
Table of Contents
- 1. What tree shaking is and why it matters
- 2. Why ES modules are mandatory: static import analysis
- 3. The sideEffects flag in package.json
- 4. Code patterns that prevent tree shaking
- 5. Writing tree-shaking-friendly libraries
- 6. Configuring tree shaking with Webpack and Rollup
- 7. Tree shaking: ESM vs. CJS vs. IIFE compared
- 8. Bundle analysis: making dead code visible with tools
- 9. Scope hoisting: the partner of tree shaking
- 10. Summary
- 11. FAQ
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.