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.


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.



← Back to Home