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.
The Three Layers Where Bytes Are Decided
Bundle size is not one problem with one fix. It is decided at three separate layers, by three different people, and a technique that works at one layer does nothing at the others. Knowing which layer you are working at prevents the common frustration of applying a fix and measuring no change at all.
The asymmetry is worth stating plainly: a library author controls layers one and two and can only influence layer three, through documentation and through the shape of the public API. That influence is real, though. A package whose examples all show import * as lib from "my-lib" teaches every consumer the most expensive form; changing the examples to named imports costs nothing and changes the default behaviour of everyone who copies them.
The reverse asymmetry matters too. An application team can do everything right at layer three and still ship a large bundle if the library authors ignored layers one and two — which is why measuring a dependency’s single-symbol cost before adopting it is a legitimate part of choosing between two libraries with similar APIs.
What Tree-Shaking Cannot Do
Optimisation effort is wasted when it targets something elimination was never going to remove. Four categories are structurally out of reach, and recognising them early redirects the work to where it pays.
Code your consumer actually calls. The obvious one, and worth stating because size investigations often end here: sometimes the bytes are load-bearing. The fix is a smaller implementation or a lazier one, not a bundler setting.
Anything reached through a dynamic key. handlers[name], require(variable), import(templateLiteral) — all defeat static analysis by construction. A bundler that guessed would be a bundler that breaks programs. Where dynamic dispatch is genuinely needed, an explicit map at the call site ({ csv: () => import("./csv.js"), json: () => import("./json.js") }) keeps the dispatch dynamic while making the set of possibilities static.
Runtime type information from decorators or reflection. Decorators execute at definition time and frequently register metadata, which makes both the decorator and its target unremovable. This is a design trade-off inherent to the pattern rather than a tooling gap.
Polyfills and prototype patches. Their entire purpose is a side effect on a shared global, so they must run when imported. What can be done is scoping them behind an explicit subpath so only consumers who need them pay, rather than shipping them from the root entry.
A fifth case deserves separate mention because it looks like a tooling failure and is not: CommonJS anywhere in the chain. If the consumer’s resolution lands on your require condition, the module graph is require()-based, exports are properties on a runtime object, and precise elimination is impossible — the bundler retains the module and hopes the minifier finds something. This is why the single highest-leverage packaging decision for size is making sure real ESM sits behind the import condition, and why a package that ships “ESM” that is actually transpiled CommonJS with an .mjs extension gets none of the benefit.
Measuring the Thing Consumers Feel
A size number is only useful if it corresponds to something a user experiences. Three metrics are worth tracking, and they answer different questions.
Single-symbol gzipped size is the tree-shaking headline: what does it cost to use one function from this package? This is the number to publish in a README and to enforce in CI, because it is the one most sensitive to the packaging mistakes that this section is about.
Full-entry gzipped size is the pessimistic bound: what does a consumer pay who imports everything, or whose bundler fails to eliminate anything? Watching the gap between this and the previous number tells you how much of the package’s total weight is genuinely optional.
Install footprint — the on-disk size of the package plus its dependency tree — is invisible in a bundle but very visible in CI times, Docker image sizes, and cold serverless starts. A package with a small bundle and a 30 MB dependency tree is a different kind of expensive.
# 1 + 2: single-symbol and full-entry, gzipped
printf 'import { formatDate } from "@scope/my-library";\nconsole.log(formatDate(new Date()));\n' > one.ts
printf 'import * as lib from "@scope/my-library";\nconsole.log(Object.keys(lib).length);\n' > all.ts
for f in one all; do
npx esbuild "$f.ts" --bundle --format=esm --minify --outfile="$f.mjs" >/dev/null 2>&1
printf '%-6s gzip=%s bytes\n' "$f" "$(gzip -c "$f.mjs" | wc -c)"
done
# 3: install footprint in a clean sandbox
d=$(mktemp -d) && (cd "$d" && npm init -y >/dev/null && npm install @scope/my-library --silent \
&& du -sh node_modules | cut -f1 | xargs -I{} echo "install footprint: {}")
one gzip=1642 bytes
all gzip=9871 bytes
install footprint: 2.1M
Those three numbers, recorded per release, turn size from an argument into a fact. And the ratio between the first two — here, roughly six to one — is the clearest single indicator of whether a package is genuinely modular or merely claims to be.
Communicating Size to Consumers
A library that tree-shakes well should say so in a way consumers can verify, because “lightweight” in a README means nothing and everybody writes it. Three lines of honest documentation do more than any adjective.
State the single-symbol cost, the full-entry cost, and the subpath map, then show the command that reproduces them. That last part is what turns a claim into a check: a consumer who doubts the number can run it in under a minute, and a maintainer who breaks it will be told by their own CI first.
It also helps to document the shape of the package rather than only its numbers. A short table of which subpath contains what, and which entries are heavy, lets an application team make an informed import decision without reading your source:
| Entry point | Gzipped, imported alone | Contains |
|---|---|---|
my-lib |
1.6 kB | core formatting and parsing |
my-lib/locale |
14 kB | full locale data — import only if you need non-English output |
my-lib/plugins/* |
0.3–2 kB each | optional plugins, one module per plugin |
The middle row is the useful one. A consumer who reads it knows to avoid the locale entry in a size-sensitive context, and a consumer who genuinely needs it is no longer surprised by 14 kB appearing in their bundle report. Publishing that table honestly — including the entries you wish were smaller — earns more trust than a badge, and it converts size questions from support threads into a link.
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 →
Reducing Dependency Weight in Published Packages
The install footprint no amount of tree-shaking touches: classifying dependencies as regular, peer or optional, keeping them external to your build output, measuring the transitive tail a consumer inherits, and removing helpers a modern runtime has replaced. Read the 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.