Tree-Shaking & Bundle Optimization
Dead code elimination, sideEffects configuration, barrel file anti-patterns, and measuring real bundle impact for optimized frontend library distribution.
This guide covers every layer of dead code elimination — from module format choices and package.json manifest configuration through barrel file restructuring, minifier tuning, and CI-enforced size budgets. It is written for library authors who need consumers to ship only the code they actually use.
Quick Reference
| Concept | One-line definition |
|---|---|
| Dead code elimination (DCE) | The bundler discards exports that no code path imports |
sideEffects flag |
A package.json field that tells bundlers every module is pure and safe to drop |
| Barrel file | An index.ts that re-exports from many sub-modules, breaking static analysis |
| Conditional exports | "exports" map entries keyed on "import" / "require" routing bundlers to the right artifact |
Pure annotation (/*#__PURE__*/) |
Inline comment that marks a call expression as side-effect-free for minifiers |
| Advanced DCE | Minifier flags, environment variable inlining, and scope-hoisting for maximum compression |
| Frontend bundle optimization | Browser-specific constraints: polyfill elimination, code splitting, TTI budgets |
Core Concept 1 — Why ESM Enables Static Analysis and CJS Does Not
Tree-shaking is a static analysis problem. A bundler must determine at build time which exports are reachable, then discard the rest. ECMAScript Modules (ESM) make this tractable because import and export declarations are syntactically top-level and cannot be conditional — the dependency graph is fully known before any code runs.
CommonJS (CJS) resolves modules at runtime through require(). The resolved path can be computed from any expression, so no static analyzer can safely prune CJS exports without running the code first:
// ESM: statically analyzable — the bundler sees exactly what is imported
import { format } from "./formatter";
// CJS: dynamic resolution — the bundler cannot prune safely
const mod = require(process.env.LIB_PATH ?? "./formatter");
const { format } = mod;
Node.js natively executes .mjs files and any .js file inside a package with "type": "module" without a transpilation step, preserving strict lexical scoping and enabling scope hoisting in Rollup. When a dual-package ships both formats, configure the exports field so bundlers resolve the "import" condition and pick the ESM artifact automatically.
TypeScript 5 introduced moduleResolution: "bundler", which aligns type resolution with the semantics bundlers actually apply — it understands "exports" maps and bare extensions like .js-pointing-at-.ts. This removes friction between TypeScript and your bundler but does not itself enable tree-shaking; that depends on the bundler receiving ESM.
Core Concept 2 — The package.json Manifest Controls What Bundlers See
The manifest is the contract between your package and every consumer’s toolchain. Two fields determine whether tree-shaking succeeds before any bundler touches source code.
Conditional exports
Route import to your .mjs (or .js inside an ESM package) artifact and require to your .cjs artifact. Always place "types" first in the condition object so TypeScript resolves declarations before bundlers choose a runtime file:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
},
"sideEffects": false,
"module": "./dist/index.mjs",
"main": "./dist/index.cjs"
}
The sideEffects field
Declaring "sideEffects": false tells Webpack, Rollup, and esbuild that every module in your package is pure — it makes no observable change to global state, extends no built-in prototype, and registers nothing when evaluated. The bundler can then drop any module whose exports are unused, even if it was transitively imported. For packages that contain CSS imports or global polyfills, pass an array of glob patterns to exempt those files. Full configuration patterns are covered in Implementing the sideEffects Flag Correctly.
Core Concept 3 — Source Architecture That Preserves Static Analyzability
The way you organize source files is as important as how you configure package.json. Bundlers evaluate the entire module graph when they encounter re-export aggregation — if every sub-module is re-exported through a single index file, the bundler must load and parse all of them even when the consumer only uses one export.
// Barrel pattern — forces evaluation of core, utils, and plugins
// even when the consumer only calls parse()
export * from "./core";
export * from "./utils";
export * from "./plugins";
// Explicit named re-exports — bundler can prove utils and plugins are unreachable
export { parse } from "./core/parser";
export { format } from "./utils/formatter";
The structural problem with barrel files, and how to eliminate them without breaking consumers, is covered in depth in Eliminating Barrel File Anti-Patterns. Consumers can also use deep import paths (import { parse } from "your-lib/core/parser") when your "exports" map exposes sub-paths — this bypasses the aggregation entirely.
Avoid wildcard re-exports (export * from) in your public API surface. They prevent bundlers from pruning the namespace object because the full export list is not known until the sub-module is evaluated.
Hazard / Failure-Mode Inventory
HAZARD PREVENTION — CJS fallback silently disables tree-shaking
Root cause: A consumer’s bundler resolves the
"main"field (CJS) instead of the"import"conditional export, often because the package does not set"exports"at all, or uses a Webpack version before 5. The bundle ships every export. Fix: Add a"exports"map with an"import"condition pointing at.mjs; Webpack 5+ and Rollup will prefer it automatically.
HAZARD PREVENTION —
sideEffects: falsedrops CSS and polyfill modulesRoot cause: CSS files imported inside component files have no JS exports, so bundlers classify them as dead code when
"sideEffects": falseis set globally. Fix: Use an array:"sideEffects": ["**/*.css", "src/polyfills.js"]. See Configuring sideEffects for CSS and Asset Imports for the full pattern.
HAZARD PREVENTION — Barrel files force full-package evaluation
Root cause:
export * from "./core"inside your entry file causes Rollup and Webpack to mark every module in./core/as potentially live, preventing any pruning until minification. Fix: Replace wildcard re-exports with named re-exports, or restructure into granular sub-path exports so consumers import directly from entry points that contain only what they need.
HAZARD PREVENTION —
/*#__PURE__*/annotation missing on class factory callsRoot cause: Minifiers treat class instantiation and IIFE calls as potentially side-effectful by default. Even if the result is never used, they are retained. Fix: Annotate factory calls:
export const registry = /*#__PURE__*/ createRegistry(). Rollup adds this automatically for its own output; for Babel-transpiled code, configure@babel/plugin-transform-classeswith{ pure: true }.
HAZARD PREVENTION — Scope hoisting disabled by dynamic
require()inside ESMRoot cause: A package with
"type": "module"that also containsrequire()calls (via legacy dependencies or CJS interop shims) forces the bundler to wrap the module in a CommonJS compatibility layer, disabling scope hoisting and effective DCE. Fix: Useimport()for dynamic loading and confine any CJS interop to the.cjsbuild only.
Decision Guide — Which Optimization Applies to Your Situation
Use this flowchart to map your current pain to the right approach:
Build Pipeline Configuration
Your build tool must output ESM in a format bundlers can analyze statically. tsup wraps Rollup and esbuild and produces dual-format output with declaration files in a single command. The key settings are format: ["esm", "cjs"], dts: true for declarations, treeshake: true for Rollup-backed scope hoisting, and pure to strip development-only calls before minification:
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
minify: true,
treeshake: true,
// Mark these calls as side-effect-free so minifiers can drop them
pure: ["console.log", "console.debug"],
// Inline NODE_ENV at build time to eliminate dead branches
env: { NODE_ENV: "production" },
// Produce separate chunks per export for precise consumer pruning
splitting: true,
});
esbuild alone (without tsup) supports --tree-shaking=true and --pure:console.log flags. Rollup with @rollup/plugin-terser gives the most aggressive DCE because it performs scope hoisting across module boundaries before compression. Production-grade minifier tuning — including passes, unsafe_math, and class field inlining — is covered in Advanced Dead Code Elimination Techniques.
Frontend vs. Server-Side Optimization
Browser delivery and server-side execution have different constraints, and a library that serves both must account for both build targets.
Browser targets operate under strict payload budgets. Every additional kilobyte delays Time to Interactive (TTI), hurts Core Web Vitals scores, and increases mobile data costs. A browser bundle must never include Node.js built-ins (fs, path, crypto) — Webpack and Rollup should externalize them or the build will fail or balloon. Polyfill inclusion must be intentional; modern browsers support Promise, fetch, and structuredClone natively.
Server-side targets (Node.js, edge workers, serverless) benefit from cold-start optimization rather than raw byte minimization. Static import declarations are resolved once at startup and cached by the module loader. Dynamic import() creates a new load boundary that can delay cold starts in environments without warm module caches.
Dynamic import boundaries are the primary tool for code splitting in browser contexts:
// webpack.config.js — browser production build
module.exports = {
optimization: {
splitChunks: { chunks: "all" },
runtimeChunk: "single",
usedExports: true,
sideEffects: true,
},
externals: {
// Never bundle Node.js built-ins for browser consumers
fs: "commonjs fs",
path: "commonjs path",
crypto: "commonjs crypto",
},
};
Detailed payload budgeting strategies, including BundlePhobia integration and Webpack Bundle Analyzer workflow, are in Optimizing Bundle Size for Frontend Libraries.
Validating Tree-Shaking in Practice
A bundle that appears optimized may still carry dead code invisibly. The only reliable validation is to measure a real consumer build.
Step 1 — Create a test consumer. In a separate directory, install your package and import a single named export. Build with production settings and measure the output size.
mkdir /tmp/shake-test && cd /tmp/shake-test
npm init -y
npm install your-lib
# Minimal consumer — imports only one export
echo 'import { parse } from "your-lib"; console.log(parse);' > index.js
npx rollup index.js --file out.js --format iife --plugin @rollup/plugin-node-resolve
wc -c out.js
Step 2 — Visualize retained modules. rollup-plugin-visualizer generates an interactive treemap showing which modules survived pruning and their relative byte contribution:
npx rollup-plugin-visualizer --template treemap --open
Step 3 — Enforce a CI size budget. size-limit integrates with GitHub Actions and fails the build when a named export’s gzipped size exceeds a threshold:
{
"size-limit": [
{ "path": "dist/index.mjs", "import": "{ parse }", "limit": "2 kB" },
{ "path": "dist/index.mjs", "import": "{ format }", "limit": "1.5 kB" }
]
}
npx size-limit
Step 4 — Profile initialization cost. A module that tree-shakes to a small byte count may still carry hidden runtime cost if it performs heavy initialization at module evaluation time. Measure with node --prof or the Chrome DevTools Performance panel.
Topic Index
The following pages cover each sub-topic in depth:
Implementing the sideEffects Flag Correctly
How to declare pure modules in package.json, exempt CSS and polyfill files from pruning, and validate the flag works across Webpack, Rollup, and esbuild.
Read guide →
Eliminating Barrel File Anti-Patterns
Why export * from index files block static analysis, how to refactor them into named re-exports or sub-path exports, and how to do it without breaking existing consumers.
Read guide →
Advanced Dead Code Elimination Techniques
Minifier configuration deep-dive: Terser passes and unsafe_* flags, SWC compression, scope hoisting, /*#__PURE__*/ annotation strategies, and environment variable inlining.
Read guide →
Optimizing Bundle Size for Frontend Libraries
Browser-specific payload budgets, code splitting with dynamic imports, polyfill elimination, BundlePhobia and Webpack Bundle Analyzer workflows, and size-limit CI integration.
Read guide →
Comparing Bundler Tree-Shaking Output
A measured head-to-head of Rollup, esbuild, and Webpack on the same library — why bundle sizes diverge, and how module format and sideEffects shift each tool’s result.
Read guide →
Frequently Asked Questions
Does TypeScript 5+ moduleResolution: "bundler" automatically enable tree-shaking?
No. moduleResolution: "bundler" improves how TypeScript resolves imports (understanding the "exports" map and bare .js extensions) but has no effect on what the bundler itself does. Tree-shaking depends on the bundler receiving ESM and the package declaring "sideEffects": false.
How do I verify that my dual ESM/CJS package is actually tree-shakeable?
Build a minimal test consumer that imports a single named export, run Rollup with production settings, and compare the output size against a baseline that imports the entire package. Use rollup-plugin-visualizer to see exactly which modules were retained.
Can CommonJS modules ever be tree-shaken effectively?
Only partially and heuristically. Webpack and Rollup can prune CJS in limited cases via require() call analysis, but they cannot guarantee accuracy. True, reliable tree-shaking requires ESM entry points declared through the "exports" map’s "import" condition.
What is the difference between tree-shaking and minification?
Tree-shaking removes entire modules and exports that are statically unreachable. Minification compresses what remains: renaming identifiers, removing whitespace, collapsing expressions. Both are necessary; neither substitutes for the other.
Does publishing with npm provenance affect bundle optimization?
No. Provenance attestation via npm publish --provenance records a cryptographic link from the published artifact to the CI workflow that built it — it does not modify the artifact’s contents. Run your bundle validation steps before signing so the attested artifact is already optimized.
Related
- Module System Fundamentals & Dual-Package Resolution — understanding ESM vs CJS, the dual-package hazard, and how Node.js resolves module formats before any bundler gets involved.
- Mastering the package.json exports Field — the canonical reference for conditional exports, sub-path patterns, and type declaration mapping that tree-shaking depends on.
- Navigating the Dual-Package Hazard — how shipping both ESM and CJS can cause singleton bifurcation, and the package patterns that prevent it.
- TypeScript Configuration & Build Tooling —
tsconfig.jsonsettings for library output, declaration file generation, and modern build tool configuration with tsup, Rollup, and esbuild. - Modern Build Tools: tsup, Rollup, and esbuild — side-by-side comparison of build tool capabilities and when each is the right choice for a dual-format library.