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
- Why Bundle Size Matters
- The JavaScript Execution Pipeline
- What Is Dead Code?
- What Is Tree-Shaking?
- Static Analysis: The Engine Behind Tree-Shaking
- How the Dependency Graph Is Created
- Why ES Modules Are Easier to Tree-Shake
- How Rollup, Webpack, and Vite Perform Tree-Shaking
- Side Effects: The Silent Tree-Shaking Killer
- Sub-path Exports and Package Design
- What Breaks Tree-Shaking?
- Measuring the Impact
- Advanced Techniques and Modern Alternatives
- Debugging Tree-Shaking Issues
- Common Misconceptions
- Best Practices Checklist
- 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 │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
Download — The bundle is transferred over the network. On a fast connection, this takes milliseconds; on 3G, it can take several seconds per megabyte.
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.
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.
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
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();
}
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;
}
And the application that imports from it:
// app.ts
import { add } from "./utils";
console.log(add(1, 2));
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));
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 │
└──────────────────┘
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;
}
// main.ts
import { add } from "./math";
add(2, 3);
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)
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 │
└──────────────────┘
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();
// App.tsx
import Button from "./Button";
import Modal from "./Modal";
Button();
Modal();
// Button.tsx
export default function Button() {
return "button";
}
// Modal.tsx
export default function Modal() {
return "modal";
}
// UnusedComponent.tsx
export default function UnusedComponent() {
return "unused";
}
The bundler analyzes these imports and creates a graph:
┌──────────┐
│ main.ts │
└────┬─────┘
│
▼
┌──────────┐
│ App.tsx │
└────┬─────┘
│
├──────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Button │ │ Modal │
│ .tsx │ │ .tsx │
└──────────┘ └──────────┘
┌────────────────────┐
│ UnusedComponent.tsx│ ← Not connected to the graph
└────────────────────┘
The Graph Construction Process
Start from entry points — The bundler begins with the application's entry point(s) (e.g.,
main.ts).Parse the entry module — It identifies all import statements in the entry module.
Resolve dependencies — For each import, the bundler resolves the file path and parses that module.
Recurse — Steps 2-3 repeat for each newly discovered dependency.
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)
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:
- Keep
utils.tsin the graph (because some exports are used) - Remove
subtractandmultiplyfrom 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;
}
// app.ts
import { add } from "./math";
add(2, 3);
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);
The require function is dynamic. Its argument can be computed at runtime:
const moduleName = getModuleName();
const module = require(moduleName);
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;
}
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
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;
}
// main.ts
import { add } from "./utils";
add(2, 3);
Rollup's approach:
- Creates a dependency graph starting from the entry point
- Marks
addas a used export - Detects that
subtractandmultiplyare unreachable - 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:
-
Mark phase — During the bundling process, Webpack marks unused exports with a special annotation (
/* unused harmony export */) - 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)
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
sideEffectshandling - 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 │
└──────────────┘
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;
}
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");
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
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
}
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>;
}
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"
]
}
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"
]
}
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;
}
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");
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 yoursideEffectsconfiguration. 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
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
The consumer imports from the package root:
import { Button, Modal } from "my-library";
While modern bundlers can optimize this pattern, large barrel files create several problems:
- Analysis overhead — The bundler must parse and analyze all 100+ re-exports
- Transitive dependencies — Each re-export may pull in its own dependencies
- Build time — Larger dependency graphs take longer to process
- 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";
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"
}
}
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)
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)
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/*"
}
}
}
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);
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);
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
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
}
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"]
}
4. Namespace Imports
The Problem:
import * as utils from "./utils";
utils.add(2, 3);
utils.subtract(5, 3);
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);
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}`);
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];
Static import maps allow the bundler to analyze all possible components.
6. CommonJS Output From Library Builds
The Problem:
// Source (ESM)
export function Button() {}
// Compiled output (CJS - tree-shaking info lost!)
exports.Button = Button;
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"
}
}
}
7. Class Side Effects
The Problem:
export class Analytics {
constructor() {
// Side effect at instantiation
window.analytics = this;
}
track(event: string) {
// ...
}
}
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();
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
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)
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:
- Bundle Analyzer — Visualize what's in your bundle:
# For Webpack
npx webpack-bundle-analyzer stats.json
# For Vite/Rollup
npx rollup-plugin-visualizer
- Lighthouse — Measure performance impact:
npx lighthouse https://your-app.com --output json
-
Chrome DevTools — Analyze parse/compile time:
- Open Performance tab
- Record a page load
- Check "JavaScript Compilation" in the timeline
Source Map Explorer — See which modules contribute most:
npx source-map-explorer build/static/js/*.js
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;
})();
With scope hoisting:
// All modules in one scope
// module a code
// module b code
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"));
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
Step 2: Analyze the Bundle
# Visualize bundle contents
npx rollup-plugin-visualizer
# or
npx webpack-bundle-analyzer stats.json
Step 3: Check for Common Issues
- Is your package using ESM?
# Check package.json
cat package.json | grep -E '"type"|"module"'
- Is sideEffects configured correctly?
# Check for sideEffects field
cat package.json | grep sideEffects
- Are you using named imports?
// ✗ Bad - namespace import
import * as lib from "library";
// ✓ Good - named import
import { specific } from "library";
- Are there dynamic imports?
# Search for dynamic patterns
grep -r "import(" src/
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
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
sideEffectsconfiguration - 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
sideEffectsoption - [ ] 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": falseinpackage.json(if applicable) - [ ] Configure
exportsfield 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.jsonexportsfor 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"
}
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:
- Build a dependency graph from entry points
- Identify reachable modules and exports through static analysis
- Remove unreachable code that cannot affect the final application
- 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)