DEV Community

Cover image for Tree-Shaking : A Deep Dive
Vahid Ghadiri
Vahid Ghadiri

Posted on

Tree-Shaking : A Deep Dive

A comprehensive guide to understanding how modern JavaScript bundlers eliminate unused code, why it matters, and how to write code that enables optimal dead code elimination.


Table of Contents

  1. Why Bundle Size Matters
  2. The JavaScript Execution Pipeline
  3. What Is Dead Code?
  4. What Is Tree-Shaking?
  5. Static Analysis: The Engine Behind Tree-Shaking
  6. How the Dependency Graph Is Created
  7. Why ES Modules Are Easier to Tree-Shake
  8. How Rollup, Webpack, and Vite Perform Tree-Shaking
  9. Side Effects: The Silent Tree-Shaking Killer
  10. Sub-path Exports and Package Design
  11. What Breaks Tree-Shaking?
  12. Measuring the Impact
  13. Advanced Techniques and Modern Alternatives
  14. Debugging Tree-Shaking Issues
  15. Common Misconceptions
  16. Best Practices Checklist
  17. Conclusion

Why Bundle Size Matters

Modern web applications often rely on hundreds—or even thousands—of JavaScript modules. Before these applications can run in a browser, a bundler such as Rollup, Webpack, or Vite combines those modules into one or more optimized output files known as bundles.

The size of these bundles has a direct, measurable impact on application performance. A larger bundle is not only slower to download over the network, but it also requires significantly more work from the browser before the application becomes interactive.

Key Insight: Every kilobyte of JavaScript has a cost that extends far beyond network transfer. The true cost includes parsing, compilation, memory allocation, and execution time—costs that scale with code size regardless of whether that code is ever actually used.

Real-World Impact

Consider these performance implications:

Metric Impact
Time to Interactive (TTI) Directly correlated with total JavaScript size
First Contentful Paint (FCP) Delayed by render-blocking JavaScript
Memory Usage Each function and variable consumes heap space
Battery Drain More code = more CPU cycles = more energy
Core Web Vitals LCP, INP, and CLS all degrade with larger bundles

A study by Google found that 53% of mobile users abandon sites that take longer than 3 seconds to load. Every additional 100KB of JavaScript can add 1-2 seconds of load time on slow mobile connections.


The JavaScript Execution Pipeline

When a browser receives a JavaScript bundle, it goes through a multi-stage pipeline. Understanding this pipeline is essential for grasping why bundle size matters at a fundamental level.

┌─────────────────────────────────────────────────────────┐
│                  Browser JS Pipeline                     │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐          │
│  │ Download │───▶│  Parse   │───▶│ Compile  │          │
│  └──────────┘    └──────────┘    └──────────┘          │
│                                       │                  │
│                                       ▼                  │
│                                  ┌──────────┐           │
│                                  │ Execute  │           │
│                                  └──────────┘           │
│                                                          │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  1. Download — The bundle is transferred over the network. On a fast connection, this takes milliseconds; on 3G, it can take several seconds per megabyte.

  2. Parse — The JavaScript source code is parsed into an internal representation (AST). This is where hidden costs begin. Parsing is CPU-intensive and scales linearly with code size—even for code that will never execute.

  3. Compile — The JavaScript engine (V8, SpiderMonkey, JavaScriptCore) compiles the parsed code into executable bytecode or optimized machine code via JIT compilation. Unused code still gets compiled if it's reachable in the AST.

  4. Execute — The compiled code is finally executed. Only this stage actually runs your logic, but the previous three stages must process all code first.

Critical Point: Even code that is never executed still consumes resources during parsing and compilation. A 500KB bundle where only 100KB is actually used still requires the browser to parse and compile all 500KB.

The Parse/Compile Tax

Modern JavaScript engines use lazy compilation and tiered compilation to mitigate some costs. V8, for example, first interprets code and only compiles hot functions into optimized machine code. However:

  • Parsing is not lazy for top-level code — all module-level declarations are parsed immediately
  • Dead code still occupies memory — parsed AST nodes consume heap space
  • Garbage collection overhead — unused objects increase GC pressure

This is why modern build tools invest heavily in optimization techniques such as minification, code splitting, and tree shaking. Their shared goal is simple: reduce the amount of JavaScript the browser needs to download and process, resulting in faster startup times, lower memory usage, and a more responsive user experience.


What Is Dead Code?

Dead code refers to code that exists in a program but has no impact on its final behavior. In other words, it is code that is never executed, never referenced, or produces no observable effect.

Consider the following example:

function add(a: number, b: number) {
  return a + b;
}

function subtract(a: number, b: number) {
  return a - b; // ← This function is never called
}

console.log(add(2, 3)); // Only `add` is used
Enter fullscreen mode Exit fullscreen mode

In this example, the subtract function is dead code because there is no execution path that uses it.

Forms of Dead Code

Dead code manifests in several distinct forms:

Type Description Example
Unused functions Functions defined but never called function unused() {}
Unused variables Variables assigned but never read const x = 42; (never used)
Unused exports Exported symbols never imported elsewhere export function helper() {}
Unreachable code Code after return, throw, or break return; console.log("hi");
Dead branches Conditional branches that can never be true if (false) { ... }
Unused classes Classes instantiated but never used class OldComponent {}

Unused Exports: The Modern Challenge

Unused exports are especially important in modern JavaScript applications because they represent the primary target for tree-shaking:

// utils.ts
export function formatDate(date: Date) {
  return date.toLocaleDateString();
}

export function formatCurrency(amount: number) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(amount);
}

export function calculateAge(birthDate: Date) {
  const today = new Date();
  return today.getFullYear() - birthDate.getFullYear();
}
Enter fullscreen mode Exit fullscreen mode

If an application only imports formatDate, the remaining exports formatCurrency and calculateAge become candidates for removal during the bundling process. However, the bundler must be certain that removing them won't change the application's behavior.

Why Removing Dead Code Is Non-Trivial

Removing code is not always straightforward. A bundler must guarantee that deleting a piece of code does not change the observable behavior of the application. This guarantee requires understanding:

  • Whether the code has side effects
  • Whether the code is reachable through dynamic patterns
  • Whether the code modifies shared state
  • Whether the code registers event handlers or observers

This is where concepts such as static analysis and side effects become critical.

Dead code elimination is one of the fundamental optimizations behind modern bundlers and is the foundation of techniques such as tree shaking.


What Is Tree-Shaking?

Tree-shaking is a build-time optimization technique used by modern JavaScript bundlers to remove unused code from the final bundle. The term was coined by Rollup creator Rich Harris, borrowing from the computer science concept of shaking unused leaves from a tree.

At its core, tree-shaking is based on static analysis. Instead of executing the application, the bundler analyzes the structure of the source code—especially module imports and exports—to determine which parts of the application are actually reachable and required.

How Tree-Shaking Works: A Concrete Example

Consider the following module:

// utils.ts

export function add(a: number, b: number) {
  return a + b;
}

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

export function multiply(a: number, b: number) {
  return a * b;
}
Enter fullscreen mode Exit fullscreen mode

And the application that imports from it:

// app.ts

import { add } from "./utils";

console.log(add(1, 2));
Enter fullscreen mode Exit fullscreen mode

Without tree-shaking, the final bundle may contain all three functions—add, subtract, and multiply—even though only add is ever used.

With tree-shaking, the bundler can detect that only add is used and remove subtract and multiply from the final output. The result might look like:

// Bundle output (after tree-shaking)
function add(a, b) {
  return a + b;
}

console.log(add(1, 2));
Enter fullscreen mode Exit fullscreen mode

The Metaphor

The term "tree-shaking" comes from the idea of removing dead branches from a tree. In practice, bundlers do not manipulate a literal tree. Instead, they build a dependency graph and eliminate modules or exports that are not reachable from the application's entry points—much like pruning dead branches from a living tree.

The Tree-Shaking Pipeline

Tree-shaking happens during the build process as part of a larger optimization pipeline:

┌──────────────────┐
│   Source Code    │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│     Bundler      │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  Static Analysis │ ◄── Parse imports/exports
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  Build Dep Graph │ ◄── Map module relationships
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│   Tree-Shaking   │ ◄── Mark unused exports
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  Code Removal    │ ◄── Strip dead code
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Minification     │ ◄── Compress remaining code
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Optimized Bundle │
└──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Tree-shaking is a specific form of dead code elimination focused on modern module systems, especially ES Modules. It relies on the fact that ES Module imports and exports are statically analyzable, allowing bundlers to determine which code can be safely removed before the application reaches the browser.

Important: Tree-shaking is not magic. It is a deterministic process based on static analysis. If a bundler cannot prove that a piece of code is unused, it will not remove it—regardless of whether that code is actually needed.


Static Analysis: The Engine Behind Tree-Shaking

Static analysis is the process of analyzing source code without executing it. Instead of running the application and observing its behavior, tools inspect the structure of the code to gather information and make decisions before runtime.

Modern JavaScript bundlers rely heavily on static analysis to perform optimizations such as tree-shaking. A bundler needs to determine which modules and exports are actually used, and it must do this before generating the final bundle.

How Static Analysis Works in Practice

Consider the following example:

// math.ts

export function add(a: number, b: number) {
  return a + b;
}

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

export function multiply(a: number, b: number) {
  return a * b;
}
Enter fullscreen mode Exit fullscreen mode
// main.ts

import { add } from "./math";

add(2, 3);
Enter fullscreen mode Exit fullscreen mode

By analyzing the import and export relationships, the bundler can determine:

Export Status Reason
add ✓ Reachable Imported by main.ts and called
subtract ✗ Unreachable Never imported by any module
multiply ✗ Unreachable Never imported by any module

This process can be visualized as:

main.ts
   │
   │  imports { add }
   │
   ▼
math.ts
   │
   ├── add         ✓ reachable (used)
   ├── subtract    ✗ unreachable (unused)
   └── multiply    ✗ unreachable (unused)
Enter fullscreen mode Exit fullscreen mode

The AST Pipeline

Under the hood, static analysis works by transforming source code into an Abstract Syntax Tree (AST):

┌──────────────────┐
│   Source Code    │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│    Tokenizer     │  Split code into tokens
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│     Parser       │  Build AST from tokens
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  Static Analysis │  Analyze AST structure
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│   Optimization   │  Apply transformations
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  Bundle Output   │
└──────────────────┘
Enter fullscreen mode Exit fullscreen mode

For example, the expression import { add } from "./math" is parsed into an AST node that explicitly declares:

  • The import specifier ("./math")
  • The imported binding (add)
  • The import type (named import)

This structured representation allows the bundler to track exactly which exports are used across the entire application.

Limitations of Static Analysis

However, JavaScript's dynamic nature creates fundamental limitations. Patterns such as:

  • Dynamic imports: import("./components/" + componentName)
  • Runtime module resolution: require(dynamicVariable)
  • Property access through unknown objects: utils[functionName]()
  • Conditional requires: if (condition) require("module-a")

...can make it difficult or impossible for a bundler to determine what code will be used ahead of time. In these cases, the bundler must err on the side of caution and include the code in the bundle.

Rule of Thumb: The more static and predictable your import patterns are, the better tree-shaking can work. Dynamic patterns force the bundler to make conservative assumptions.


How the Dependency Graph Is Created

A dependency graph is a directed graph that represents the relationships between modules in an application. It describes which modules depend on other modules through their import statements.

Modern bundlers use this graph to understand the structure of an application and determine which parts of the code are required in the final bundle.

Building the Graph: Step by Step

Consider this application:

// main.ts
import App from "./App";

App();
Enter fullscreen mode Exit fullscreen mode
// App.tsx
import Button from "./Button";
import Modal from "./Modal";

Button();
Modal();
Enter fullscreen mode Exit fullscreen mode
// Button.tsx
export default function Button() {
  return "button";
}
Enter fullscreen mode Exit fullscreen mode
// Modal.tsx
export default function Modal() {
  return "modal";
}
Enter fullscreen mode Exit fullscreen mode
// UnusedComponent.tsx
export default function UnusedComponent() {
  return "unused";
}
Enter fullscreen mode Exit fullscreen mode

The bundler analyzes these imports and creates a graph:

┌──────────┐
│ main.ts  │
└────┬─────┘
     │
     ▼
┌──────────┐
│ App.tsx  │
└────┬─────┘
     │
     ├──────────────┐
     ▼              ▼
┌──────────┐  ┌──────────┐
│ Button   │  │  Modal   │
│  .tsx    │  │  .tsx    │
└──────────┘  └──────────┘


┌────────────────────┐
│ UnusedComponent.tsx│  ← Not connected to the graph
└────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The Graph Construction Process

  1. Start from entry points — The bundler begins with the application's entry point(s) (e.g., main.ts).

  2. Parse the entry module — It identifies all import statements in the entry module.

  3. Resolve dependencies — For each import, the bundler resolves the file path and parses that module.

  4. Recurse — Steps 2-3 repeat for each newly discovered dependency.

  5. Build the complete graph — When no new dependencies are found, the full module graph is complete.

Graph Anatomy

Internally, this graph consists of:

  • Nodes — Individual modules or files (e.g., main.ts, App.tsx, Button.tsx)
  • Edges — Relationships between modules, created by import statements

Once the dependency graph exists, the bundler can perform reachability analysis:

main.ts ──▶ App.tsx ──▶ Button.tsx
                 └──▶ Modal.tsx

UnusedComponent.tsx (disconnected)
Enter fullscreen mode Exit fullscreen mode

UnusedComponent.tsx is not connected to the application graph, meaning there is no path from the entry point to that module. Therefore, it can be removed during optimization.

Export-Level Analysis

Modern bundlers can also analyze dependencies at the export level, not only the module level. This allows them to remove individual unused exports from a module while keeping the required ones.

For example, if utils.ts exports add, subtract, and multiply, but only add is imported, the bundler can:

  1. Keep utils.ts in the graph (because some exports are used)
  2. Remove subtract and multiply from the final bundle

This granular analysis is what makes tree-shaking significantly more effective than simply removing entire unused files.

Foundation: The dependency graph is the foundation of many modern bundling optimizations, including tree-shaking, code splitting, and module-level optimization.


Why ES Modules Are Easier to Tree-Shake

Tree-shaking relies on static analysis. In order for a bundler to safely remove unused code, it needs to understand the relationships between imports and exports before the application runs.

This is where ES Modules (ESM) have a significant advantage over CommonJS.

ES Modules: Static by Design

Consider the following ES Module:

// math.ts

export function add(a: number, b: number) {
  return a + b;
}

export function subtract(a: number, b: number) {
  return a - b;
}
Enter fullscreen mode Exit fullscreen mode
// app.ts

import { add } from "./math";

add(2, 3);
Enter fullscreen mode Exit fullscreen mode

The bundler can statically determine that add is used while subtract is not. Because ES Module imports and exports are part of the language syntax and their structure is known ahead of time, they can be analyzed during the build process.

Key characteristics of ES Modules that enable tree-shaking:

Feature Description
Static syntax import/export are declared at the top level, not inside functions or conditions
Static bindings Import bindings are live read-only views of exports
No runtime mutation You cannot add new exports after declaration
Fixed specifiers Import paths must be string literals (not computed)

CommonJS: Dynamic by Nature

CommonJS works differently:

const math = require("./math");

math.add(2, 3);
Enter fullscreen mode Exit fullscreen mode

The require function is dynamic. Its argument can be computed at runtime:

const moduleName = getModuleName();

const module = require(moduleName);
Enter fullscreen mode Exit fullscreen mode

At build time, a bundler cannot always determine which module will be loaded or which exports will be accessed.

Another critical difference is that CommonJS modules can modify their exports dynamically:

// Dynamic property addition
module.exports.newFeature = feature;

// Conditional exports
if (process.env.NODE_ENV === "development") {
  module.exports.debug = true;
}
Enter fullscreen mode Exit fullscreen mode

This dynamic behavior makes it much harder for tools to safely remove unused code.

Side-by-Side Comparison

ES Modules (Static)                CommonJS (Dynamic)
─────────────────────              ─────────────────────
import { add }                     const math = require("./math")
       │                                    │
       ▼                                    ▼
┌─────────────┐                   ┌─────────────────┐
│ Static AST  │                   │ Runtime function │
│ Analysis    │                   │ call - opaque   │
└─────────────┘                   └─────────────────┘
       │                                    │
       ▼                                    ▼
  ✓ Analyzable                    ✗ Hard to analyze
  ✓ Tree-shakeable                ✗ Conservative inclusion
Enter fullscreen mode Exit fullscreen mode

The Bottom Line

ES Modules provide a predictable module structure, static import/export declarations, and analyzable dependency relationships. These characteristics make them the ideal foundation for tree-shaking and other build-time optimizations.

Nuance: CommonJS is not completely impossible to optimize—tools like Webpack perform heuristic analysis on CommonJS patterns. However, ES Modules provide the level of static information that modern bundlers need to perform reliable, comprehensive tree-shaking.


How Rollup, Webpack, and Vite Perform Tree-Shaking

Modern bundlers use similar high-level strategies to perform tree-shaking. They analyze the application's module structure, create a dependency graph, determine which exports are reachable, and remove unused code during the build process.

However, each bundler implements this process differently, with distinct trade-offs and capabilities.

Rollup

Rollup is one of the bundlers most closely associated with tree-shaking. It was designed from the ground up around the ES Module ecosystem and performs static analysis directly on module imports and exports.

// utils.ts
export function add(a: number, b: number) {
  return a + b;
}

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

export function multiply(a: number, b: number) {
  return a * b;
}
Enter fullscreen mode Exit fullscreen mode
// main.ts
import { add } from "./utils";

add(2, 3);
Enter fullscreen mode Exit fullscreen mode

Rollup's approach:

  1. Creates a dependency graph starting from the entry point
  2. Marks add as a used export
  3. Detects that subtract and multiply are unreachable
  4. Removes unused exports at the export level (not just module level)

Rollup performs tree-shaking at the export level, allowing it to remove unused parts of a module instead of removing only entire files. This makes it exceptionally effective for libraries with many small exports.

Rollup's Strengths:

  • ESM-first design with excellent static analysis
  • Export-level tree-shaking (fine-grained)
  • Clean, predictable output
  • Plugin ecosystem focused on ESM

Webpack

Webpack also supports tree-shaking through its optimization pipeline. It builds a module graph, analyzes used exports, and marks unused exports during compilation.

Webpack typically performs a two-phase approach:

  1. Mark phase — During the bundling process, Webpack marks unused exports with a special annotation (/* unused harmony export */)
  2. Remove phase — During minification (commonly using Terser), the marked code is stripped from the output
Webpack Pipeline:

Source ──▶ Build Graph ──▶ Mark Unused ──▶ Minify ──▶ Output
                           Exports        (Terser)
Enter fullscreen mode Exit fullscreen mode

Webpack's Complexity:

Because Webpack provides many runtime features and supports a wide range of module patterns (CommonJS, AMD, ESM, mixed), its analysis can sometimes be more complex than Rollup's. Webpack must also handle:

  • Code splitting and dynamic imports
  • Module federation
  • Runtime module loading
  • Hot module replacement (HMR)

Webpack 5 Improvements:

  • Improved sideEffects handling
  • Better export mangling
  • Module concatenation (scope hoisting) improvements
  • Inner graph analysis for better dead code detection

Vite

Vite uses a fundamentally different approach during development:

  • Development: Instead of bundling, Vite relies on native ES Modules and serves modules directly to the browser. This means no tree-shaking during development—each module is served individually.

  • Production: Vite uses Rollup internally, so production tree-shaking behavior is based on Rollup's implementation.

Vite Architecture:

Development:              Production:
┌──────────────┐         ┌──────────────┐
│    Vite     │         │    Vite     │
└──────┬───────┘         └──────┬───────┘
       │                        │
       ▼                        ▼
┌──────────────┐         ┌──────────────┐
│ Native ESM   │         │   Rollup    │
│ (no bundle)  │         │ (full build)│
└──────────────┘         └──────┬───────┘
                                │
                                ▼
                         ┌──────────────┐
                         │ Tree-Shaking │
                         └──────┬───────┘
                                │
                                ▼
                         ┌──────────────┐
                         │ Optimized   │
                         │ Bundle      │
                         └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Vite's Advantages:

  • Near-instant development startup (no bundling overhead)
  • Production builds leverage Rollup's mature tree-shaking
  • Excellent developer experience with ESM-native development

Comparison Table

Feature Rollup Webpack Vite
Tree-shaking granularity Export-level Export-level Export-level (via Rollup)
Dev mode Bundled Bundled Native ESM (no bundle)
Production engine Rollup Webpack Rollup
ESM support Native Good (v5+) Native
CJS handling Limited Comprehensive Via Rollup
Config complexity Low High Low
Build speed Fast Moderate Fast

Although these tools have different architectures, they all rely on the same fundamental principles: static analysis, dependency graphs, and the ability to understand ES Module relationships before runtime.


Side Effects: The Silent Tree-Shaking Killer

Tree-shaking is based on a simple assumption: if a piece of code is not used, it can be safely removed.

However, this assumption is not always true. Some code can affect the application even when it does not export anything or is never explicitly called. This is known as a side effect.

What Are Side Effects?

A side effect occurs when executing a piece of code changes something outside of its own scope.

Consider a pure function:

function add(a: number, b: number) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

This function is pure—it only depends on its inputs and produces an output. It doesn't modify anything outside itself, so removing it is safe if it's unused.

However, the following code has a side effect:

console.log("Application started");
Enter fullscreen mode Exit fullscreen mode

The code modifies an external environment by writing to the browser console. Even if no function references this code, removing it would change the application's observable behavior.

Common Side Effects

Side effects appear in many forms:

Category Examples
Global state mutation window.myApp = {}, globalThis.config = {}
DOM manipulation document.createElement(...), custom element registration
Network requests fetch(...), XMLHttpRequest
Storage access localStorage.setItem(...), cookie = "..."
CSS injection import "./styles.css", document.head.appendChild(style)
Analytics/tracking gtag(...), analytics.track(...)
Polyfills if (!Array.prototype.flat) { ... }
Event listeners window.addEventListener("load", ...)

Why Bundlers Can't Simply Remove Everything

Consider this example:

// setup.ts
initializeAnalytics();
loadPolyfills();
registerServiceWorker();

// app.ts
import "./setup"; // Side-effect-only import
Enter fullscreen mode Exit fullscreen mode

Although setup.ts does not export anything, removing it would break the application because initializeAnalytics() and other initialization code would never execute.

This is why bundlers cannot simply remove every module that doesn't export anything. They must determine whether removing a module is safe.

The sideEffects Field in package.json

Package authors can provide additional information to bundlers through the sideEffects field in package.json.

Marking a Package as Side-Effect Free

{
  "sideEffects": false
}
Enter fullscreen mode Exit fullscreen mode

This tells bundlers:

All files in this package are free of side effects and can be safely removed when unused.

This configuration is common for libraries that only provide reusable functions, components, or icons. For example, an icon library:

export function HomeIcon() {
  return <svg>...</svg>;
}

export function SettingsIcon() {
  return <svg>...</svg>;
}
Enter fullscreen mode Exit fullscreen mode

Since rendering an icon doesn't modify global state or perform external operations, unused icons can safely be removed.

Preserving Specific Files

Some packages need to preserve specific files that have side effects:

{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js",
    "./src/global-setup.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

This tells the bundler that:

  • JavaScript modules may be tree-shaken
  • CSS files should always be included (they affect styling)
  • Specific files with initialization logic must be preserved

Granular Side-Effects Declaration

You can also be more specific:

{
  "sideEffects": [
    "./src/polyfills.js",
    "./src/styles/global.css",
    "./src/setup.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

This approach is more precise and gives bundlers the maximum information for optimization.

Function Side Effects vs Module Side Effects

It's important to distinguish between two types of side effects:

Function side effects — A function performs an external action when called:

function saveUser(user: User) {
  localStorage.setItem("user", JSON.stringify(user));
  return user;
}
Enter fullscreen mode Exit fullscreen mode

Module side effects — Code executes immediately when the module is imported:

// This code runs at import time
localStorage.setItem("theme", "dark");
document.body.classList.add("theme-dark");
Enter fullscreen mode Exit fullscreen mode

Tree-shaking mainly needs to consider module-level side effects because they happen automatically during module evaluation. A function with side effects is only problematic if it's actually called—module-level side effects run unconditionally.

Best Practice: Keep side effects isolated in dedicated modules (e.g., setup.ts, polyfills.ts) and declare them in your sideEffects configuration. This makes it easier for bundlers to determine what can be safely removed.


Sub-path Exports and Package Design

Another important factor that affects tree-shaking is how a package exposes its modules.

The Barrel File Problem

Consider a library with many components:

my-library/
├── src/
│   ├── Button.tsx
│   ├── Modal.tsx
│   ├── Table.tsx
│   ├── Input.tsx
│   ├── Select.tsx
│   ├── Tabs.tsx
│   └── ... (100+ components)
└── index.ts          ← Barrel file
Enter fullscreen mode Exit fullscreen mode

A common approach is exposing everything through a single entry point via a barrel file:

// index.ts (barrel file)
export * from "./Button";
export * from "./Modal";
export * from "./Table";
export * from "./Input";
export * from "./Select";
export * from "./Tabs";
// ... 100+ re-exports
Enter fullscreen mode Exit fullscreen mode

The consumer imports from the package root:

import { Button, Modal } from "my-library";
Enter fullscreen mode Exit fullscreen mode

While modern bundlers can optimize this pattern, large barrel files create several problems:

  1. Analysis overhead — The bundler must parse and analyze all 100+ re-exports
  2. Transitive dependencies — Each re-export may pull in its own dependencies
  3. Build time — Larger dependency graphs take longer to process
  4. Incorrect tree-shaking — Some bundlers may struggle with deeply nested re-exports

What Are Sub-path Exports?

Sub-path exports allow a package to expose specific entry points instead of forcing consumers to import everything from the package root.

// Instead of this:
import { Button } from "my-library";

// Consumers can do this:
import Button from "my-library/Button";
Enter fullscreen mode Exit fullscreen mode

The package defines these paths using the exports field in package.json:

{
  "exports": {
    ".": "./dist/index.js",
    "./Button": "./dist/components/Button.js",
    "./Modal": "./dist/components/Modal.js",
    "./Table": "./dist/components/Table.js",
    "./Input": "./dist/components/Input.js",
    "./styles": "./dist/styles.css"
  }
}
Enter fullscreen mode Exit fullscreen mode

Why Sub-path Exports Help Tree-Shaking

Sub-path exports dramatically reduce the amount of information the bundler needs to analyze.

Without sub-path exports:

Application
     │
     ▼
  index.js (barrel)
     │
     ├──▶ Button.js
     ├──▶ Modal.js
     ├──▶ Table.js
     ├──▶ Input.js
     ├──▶ Select.js
     └──▶ ... (100+ modules)
Enter fullscreen mode Exit fullscreen mode

The bundler starts from a large entry point and must analyze all exported modules, even if only one is used.

With sub-path exports:

Application
     │
     ▼
  Button.js (direct import)
Enter fullscreen mode Exit fullscreen mode

The dependency graph starts dramatically smaller, and the bundler immediately knows which module is required. There's no barrel file to parse, no re-exports to analyze, and no unnecessary modules to consider.

Path Patterns

Modern exports configuration supports patterns for scalability:

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./components/*": {
      "import": "./dist/components/*.js"
    },
    "./icons/*": {
      "import": "./dist/icons/*.js"
    },
    "./styles/*": {
      "import": "./dist/styles/*"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This allows consumers to import specific components without the package needing to enumerate every possible export.


What Breaks Tree-Shaking?

Tree-shaking is a powerful optimization technique, but it is not guaranteed to work in every situation. Bundlers can only remove unused code when they have enough information about module relationships and when removing code is proven to be safe.

1. Using CommonJS Instead of ES Modules

The Problem:

const utils = require("./utils");

utils.add(2, 3);
Enter fullscreen mode Exit fullscreen mode

Because require() is a runtime function, a bundler cannot always determine which exports will be used before execution.

The Fix:

import { add } from "./utils";

add(2, 3);
Enter fullscreen mode Exit fullscreen mode

ES Modules provide static import and export declarations, making them much easier to analyze.

2. Large Barrel Files

The Problem:

// index.ts - 200+ re-exports
export * from "./Button";
export * from "./Modal";
export * from "./Table";
export * from "./Input";
export * from "./Select";
export * from "./Tabs";
export * from "./Accordion";
export * from "./Alert";
// ... many more
Enter fullscreen mode Exit fullscreen mode

Large chains of re-exports make dependency analysis more complicated and can slow down builds.

The Fix:

  • Use sub-path exports: import Button from "my-library/Button"
  • Keep barrel files small and focused
  • Split large packages into smaller, focused packages

3. Incorrect Side Effects Configuration

The Problem:

{
  "sideEffects": false
}
Enter fullscreen mode Exit fullscreen mode

This is incorrect for a package that imports global CSS or performs initialization during module loading. The bundler will incorrectly remove code that has side effects.

The Fix:

{
  "sideEffects": ["*.css", "./src/polyfills.js"]
}
Enter fullscreen mode Exit fullscreen mode

4. Namespace Imports

The Problem:

import * as utils from "./utils";

utils.add(2, 3);
utils.subtract(5, 3);
Enter fullscreen mode Exit fullscreen mode

Since the entire namespace object is imported, the bundler may have less information about which individual exports are actually required. Some bundlers treat namespace imports conservatively.

The Fix:

import { add, subtract } from "./utils";

add(2, 3);
subtract(5, 3);
Enter fullscreen mode Exit fullscreen mode

Named imports give the bundler explicit information about which exports are used.

5. Dynamic Module Access

The Problem:

const componentName = getComponentName(); // Runtime value
const Component = await import(`./components/${componentName}`);
Enter fullscreen mode Exit fullscreen mode

Because the final module path is only known at runtime, the bundler may need to include all possible modules that could match the pattern.

The Fix:

import { Button } from "./components/Button";
import { Modal } from "./components/Modal";
import { Table } from "./components/Table";

const componentMap = { Button, Modal, Table };
const Component = componentMap[componentName];
Enter fullscreen mode Exit fullscreen mode

Static import maps allow the bundler to analyze all possible components.

6. CommonJS Output From Library Builds

The Problem:

// Source (ESM)
export function Button() {}
Enter fullscreen mode Exit fullscreen mode
// Compiled output (CJS - tree-shaking info lost!)
exports.Button = Button;
Enter fullscreen mode Exit fullscreen mode

If a library compiles to CommonJS, much of the static information required for tree-shaking is lost.

The Fix:

Ensure your build configuration produces ESM output:

{
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Class Side Effects

The Problem:

export class Analytics {
  constructor() {
    // Side effect at instantiation
    window.analytics = this;
  }

  track(event: string) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Even if Analytics is imported but never instantiated, some bundlers may not remove it because the class definition itself could have side effects.

The Fix:

// Separate the side effect from the logic
export class Analytics {
  track(event: string) {
    // ...
  }
}

// Explicit initialization module
// setup.ts
import { Analytics } from "./Analytics";
window.analytics = new Analytics();
Enter fullscreen mode Exit fullscreen mode

Measuring the Impact

Tree-shaking isn't just a theoretical optimization—it has measurable, real-world impact on application performance.

Before and After Example

Consider a typical application using a UI library:

Before tree-shaking:

Total bundle size: 450 KB
├── UI Library: 320 KB (all components included)
├── App code: 80 KB
└── Dependencies: 50 KB
Enter fullscreen mode Exit fullscreen mode

After tree-shaking:

Total bundle size: 180 KB
├── UI Library: 60 KB (only used components)
├── App code: 80 KB
└── Dependencies: 40 KB (unused helpers removed)
Enter fullscreen mode Exit fullscreen mode

Result: 60% reduction in bundle size

Performance Metrics

Metric Before After Improvement
Bundle size 450 KB 180 KB -60%
Parse time (mobile) 800ms 320ms -60%
Compile time 400ms 160ms -60%
TTI (3G) 8.2s 3.4s -59%
Memory usage 45MB 22MB -51%

How to Measure

You can measure the impact of tree-shaking using these tools:

  1. Bundle Analyzer — Visualize what's in your bundle:
   # For Webpack
   npx webpack-bundle-analyzer stats.json

   # For Vite/Rollup
   npx rollup-plugin-visualizer
Enter fullscreen mode Exit fullscreen mode
  1. Lighthouse — Measure performance impact:
   npx lighthouse https://your-app.com --output json
Enter fullscreen mode Exit fullscreen mode
  1. Chrome DevTools — Analyze parse/compile time:

    • Open Performance tab
    • Record a page load
    • Check "JavaScript Compilation" in the timeline
  2. Source Map Explorer — See which modules contribute most:

   npx source-map-explorer build/static/js/*.js
Enter fullscreen mode Exit fullscreen mode

Advanced Techniques and Modern Alternatives

Module Concatenation (Scope Hoisting)

Module concatenation, also known as scope hoisting, is an optimization that puts multiple modules into a single function scope instead of wrapping each module in a separate function wrapper.

Without scope hoisting:

// Each module wrapped in a function
var module_a = (function() {
  var exports = {};
  // module a code
  return exports;
})();

var module_b = (function() {
  var exports = {};
  // module b code
  return exports;
})();
Enter fullscreen mode Exit fullscreen mode

With scope hoisting:

// All modules in one scope
// module a code
// module b code
Enter fullscreen mode Exit fullscreen mode

This reduces bundle size (no function wrappers), improves runtime performance (fewer function calls), and enables better optimization by the JavaScript engine.

Dynamic Import and Code Splitting

While tree-shaking removes unused code, code splitting breaks the bundle into smaller chunks that are loaded on demand:

// Route-based code splitting
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
Enter fullscreen mode Exit fullscreen mode

Code splitting and tree-shaking work together:

  • Tree-shaking removes unused code from each chunk
  • Code splitting ensures users only download what they need
  • Combined, they dramatically reduce initial load time

Turbopack and Next.js

Turbopack, Vercel's new bundler (successor to Webpack for Next.js), promises even faster builds and optimizations:

  • Incremental computation for faster rebuilds
  • Native Rust-based architecture
  • Improved tree-shaking through better module analysis

While still evolving, Turbopack represents the next generation of bundler optimization.

esbuild and SWC

esbuild (used by Vite for dev) and SWC (used by Next.js) are fast transpilers that can also perform some optimization:

  • esbuild: Extremely fast bundling but limited tree-shaking compared to Rollup
  • SWC: Fast compilation with improving optimization capabilities

These tools are often used alongside traditional bundlers to speed up the build process while delegating tree-shaking to more mature tools.


Debugging Tree-Shaking Issues

When tree-shaking doesn't work as expected, follow this debugging process:

Step 1: Check Your Bundle Output

# Generate bundle with stats
npx webpack --json > stats.json
# or
npx vite build --mode production
Enter fullscreen mode Exit fullscreen mode

Step 2: Analyze the Bundle

# Visualize bundle contents
npx rollup-plugin-visualizer
# or
npx webpack-bundle-analyzer stats.json
Enter fullscreen mode Exit fullscreen mode

Step 3: Check for Common Issues

  1. Is your package using ESM?
   # Check package.json
   cat package.json | grep -E '"type"|"module"'
Enter fullscreen mode Exit fullscreen mode
  1. Is sideEffects configured correctly?
   # Check for sideEffects field
   cat package.json | grep sideEffects
Enter fullscreen mode Exit fullscreen mode
  1. Are you using named imports?
   // ✗ Bad - namespace import
   import * as lib from "library";

   // ✓ Good - named import
   import { specific } from "library";
Enter fullscreen mode Exit fullscreen mode
  1. Are there dynamic imports?
   # Search for dynamic patterns
   grep -r "import(" src/
Enter fullscreen mode Exit fullscreen mode

Step 4: Use Bundle Analyzer

The bundle analyzer will show you exactly what's included in your bundle and where it comes from. Look for:

  • Unexpected large modules
  • Duplicate dependencies
  • Modules you expected to be removed
  • Dependencies with many unused exports

Common Debugging Commands

# Webpack stats
npx webpack --stats detailed

# Rollup with verbose output
npx rollup -c --verbose

# Vite build with analysis
npx vite build --mode production && npx vite-bundle-visualizer
Enter fullscreen mode Exit fullscreen mode

Common Misconceptions

Misconception 1: "Tree-Shaking Removes Entire Unused Files"

Reality: Tree-shaking operates at the export level, not just the module level. It can remove individual unused exports from a module while keeping the used ones. However, if a module is imported for its side effects, it will be kept entirely.

Misconception 2: "Tree-Shaking Works Automatically"

Reality: Tree-shaking requires:

  • ESM format (or CommonJS with heuristic analysis)
  • Correct sideEffects configuration
  • Static import patterns
  • Proper bundler configuration

Without these, tree-shaking may be partially or completely ineffective.

Misconception 3: "Tree-Shaking Eliminates All Dead Code"

Reality: Tree-shaking can only remove code that the bundler can prove is unused through static analysis. Dynamic patterns, side effects, and complex control flow can prevent tree-shaking from removing code that appears unused.

Misconception 4: "Smaller Bundle = Better Performance"

Reality: While bundle size matters, other factors also affect performance:

  • Code complexity — A small but computationally expensive function can be slower than a larger, simpler one
  • Network conditions — On fast networks, bundle size matters less
  • Caching — A larger cached bundle may be faster than a smaller uncached one
  • Parsing overhead — Some code structures are harder to parse than others

Misconception 5: "Tree-Shaking Is the Same as Dead Code Elimination"

Reality: Tree-shaking is a specific type of dead code elimination focused on ES Modules. Traditional dead code elimination (DCE) removes unreachable code within a module, while tree-shaking removes unused exports across modules.


Best Practices Checklist

For Application Developers

  • [ ] Use ESM syntax (import/export) instead of CommonJS (require)
  • [ ] Use named imports instead of namespace imports
  • [ ] Avoid dynamic import patterns when possible
  • [ ] Analyze your bundle regularly with visualizers
  • [ ] Configure your bundler's sideEffects option
  • [ ] Use code splitting for route-based loading
  • [ ] Keep barrel files small and focused
  • [ ] Remove unused dependencies from package.json

For Library Authors

  • [ ] Ship ESM output (in addition to CJS if needed)
  • [ ] Set "sideEffects": false in package.json (if applicable)
  • [ ] Configure exports field for sub-path imports
  • [ ] Avoid global side effects in module initialization
  • [ ] Keep barrel files minimal or provide sub-path exports
  • [ ] Test tree-shaking with bundle analyzers
  • [ ] Document which files have side effects
  • [ ] Use package.json exports for conditional imports

Example: Well-Configured Library

{
  "name": "my-library",
  "type": "module",
  "sideEffects": ["*.css", "./src/polyfills.js"],
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./Button": {
      "import": "./dist/components/Button.js"
    },
    "./Modal": {
      "import": "./dist/components/Modal.js"
    },
    "./icons/*": {
      "import": "./dist/icons/*.js"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts"
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Tree-shaking is more than just a technique for removing unused code. It is the result of multiple concepts working together: static analysis, dependency graphs, module systems, and thoughtful package design.

The Core Problem

Modern applications contain a large amount of code, but users only need a small portion of it. Shipping unnecessary code increases bundle size and negatively affects application performance—from network transfer to parsing, compilation, memory usage, and battery drain.

How the Ecosystem Solves It

To solve this problem, bundlers analyze the structure of an application before runtime:

  1. Build a dependency graph from entry points
  2. Identify reachable modules and exports through static analysis
  3. Remove unreachable code that cannot affect the final application
  4. Minify remaining code to reduce size further

The Role of ES Modules

ES Modules play a critical role in this process because their static structure allows bundlers to understand imports and exports during the build phase. This is why modern optimization strategies are designed around ESM rather than dynamic module systems such as CommonJS.

The Collaboration

Effective tree-shaking represents a collaboration between tooling and code architecture:

  • Bundlers provide the optimization engine
  • Library authors provide the static information needed for optimization
  • Application developers configure the build pipeline correctly

The better a project exposes its structure, the more opportunities modern bundlers have to produce smaller, faster, and more efficient bundles.

Looking Forward

As the JavaScript ecosystem continues to evolve, tree-shaking will become even more sophisticated:

  • Better static analysis through improved AST handling
  • Smarter side-effects detection through type information
  • Faster builds through tools like Turbopack and esbuild
  • More granular optimization through module-level code generation

Understanding tree-shaking today means understanding the future of web performance. It's not just about removing dead code—it's about building applications that are efficient, performant, and respectful of users' resources.


Final Thought: Tree-shaking is a reminder that in software engineering, what you don't ship is just as important as what you do. The best code is the code that never reaches the user's browser.

Top comments (0)