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: false drops CSS and polyfill modules

Root cause: CSS files imported inside component files have no JS exports, so bundlers classify them as dead code when "sideEffects": false is 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 calls

Root 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-classes with { pure: true }.


HAZARD PREVENTION — Scope hoisting disabled by dynamic require() inside ESM

Root cause: A package with "type": "module" that also contains require() 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: Use import() for dynamic loading and confine any CJS interop to the .cjs build only.


Decision Guide — Which Optimization Applies to Your Situation

Use this flowchart to map your current pain to the right approach:

Tree-Shaking Decision Flowchart A flowchart guiding library authors from their symptom — unused code in consumer bundles, bundle size regression, CSS being dropped, or complex minification — to the appropriate optimization technique. Consumer bundle too large? Does package.json have an "exports" map with an "import" condition? No Add conditional exports map Yes Is "sideEffects": false set in package.json? No Set sideEffects false (or array) Yes Does the entry point use export * from barrel files? Yes Eliminate barrel re-exports No Are class factories / IIFEs missing /*#__PURE__*/ annotations? Yes Add pure annotations No Targeting browsers with strict payload budgets? Yes Frontend bundle optimization No Advanced DCE: minifier flags, scope hoisting & env inlining

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.

Three layers that decide bundle size Authoring decisions such as module granularity and avoiding module-level work, packaging decisions such as shipping ESM and declaring sideEffects, and consumption decisions such as importing a subpath rather than a namespace. Each layer is owned by a different person and each has its own techniques. Each layer can undo the work of the one above it 1. authoring owned by the library author small modules, no module-level work, explicit re-exports, lazy data tables, no dynamic access into own exports 2. packaging owned by the library author real ESM behind the import condition, accurate sideEffects, subpath exports, dependencies left external 3. consumption owned by the application named imports over namespace imports, subpaths over the root, production mode enabled, one copy of each dependency

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.



← Back to Home