Basic tree-shaking removes clearly unused exports, but published packages routinely survive that first pass and still bloat consumer bundles. The problem appears across the toolchain: Webpack 5 keeps a class because one IIFE inside it lacks a /*#__PURE__*/ annotation; Rollup retains a utility module because the exports field routes bundlers to the CJS artifact instead of ESM; process.env.NODE_ENV checks survive minification because no build step replaced them with literals. This guide addresses those second-order techniques — the ones that matter once the basics are already in place. They apply to any Node.js 18+ project using Webpack 5, Rollup 3+, esbuild 0.17+, or Vite 5+.

Prerequisites

Canonical Configuration: Full Rollup DCE Setup

This is the minimal Rollup configuration that activates every elimination layer discussed below — scope hoisting, annotation-aware tree-shaking, moduleSideEffects, and environment variable replacement — in a single file:

// rollup.config.ts
import replace from "@rollup/plugin-replace";
import { defineConfig } from "rollup";

export default defineConfig({
  input: "src/index.ts",
  output: [
    {
      file: "dist/esm/index.js",
      format: "esm",
      // Keeps the output as individual modules so bundlers can
      // perform their own tree-shaking pass over your dist
      preserveModules: false,
    },
    {
      file: "dist/cjs/index.cjs",
      format: "cjs",
    },
  ],
  treeshake: {
    // Honour /*#__PURE__*/ and @__PURE__ annotations
    annotations: true,
    // Trust that every module in the graph is side-effect-free
    // unless listed in package.json sideEffects. DANGEROUS if you
    // ship CSS or register globals — use with an explicit sideEffects
    // array in package.json rather than "false" when in doubt.
    moduleSideEffects: false,
    // Remove properties from objects when only some properties are used
    propertyReadSideEffects: false,
  },
  plugins: [
    replace({
      // Replace at parse time — must be valid JS literals
      "process.env.NODE_ENV": JSON.stringify("production"),
      "import.meta.env.DEV": "false",
      "import.meta.env.PROD": "true",
      // Prevent left-hand side replacement (e.g. process.env.X = "y")
      preventAssignment: true,
    }),
  ],
});

Step 1 — Annotate Side-Effect-Free Calls with /*#__PURE__*/

Bundlers build an Abstract Syntax Tree from every module in the dependency graph. A module is kept if it has reachable exports consumed by the entry point, or if it has observable side effects at module scope. IIFEs, class declarations, and factory calls at the top level are assumed to have side effects unless explicitly marked otherwise.

The /*#__PURE__*/ comment (Rollup) and its alias @__PURE__ (Terser, esbuild) signal that the annotated call may be dropped when its return value is never consumed:

// Without annotation — bundler assumes the IIFE mutates a global and keeps it
export const createLogger = (() => {
  return (msg: string) => console.log("[lib]", msg);
})();

// With annotation — bundler may eliminate if createLogger is never imported
export const createLogger = /*#__PURE__*/ (() => {
  return (msg: string) => console.log("[lib]", msg);
})();

Class declarations are especially risky in TypeScript-compiled output. tsc and @babel/plugin-transform-classes wrap classes in IIFEs for downlevel compatibility. Without /*#__PURE__*/, the entire class is retained even if no consumer touches it. Most modern build tools (esbuild ≥ 0.14.11, SWC) emit the annotation automatically; tsc alone does not.

Expected output — before annotation:

dist/esm/index.js  4.2 kB  (createLogger included despite no import)

Expected output — after annotation:

dist/esm/index.js  1.1 kB  (createLogger eliminated)

Step 1a — Enable esbuild’s --pure flag for specific call sites

esbuild also accepts a list of globally pure functions. Any call to the named function is treated as side-effect-free and removed when the return value is unused:

esbuild src/index.ts \
  --bundle \
  --minify \
  --pure:console.log \
  --pure:debug \
  --outfile=dist/index.js

Do not declare a function pure here if it performs initialisation (registry writes, polyfill installs, network requests). The bundler will silently drop it — no warning is emitted.


Step 2 — Enable Scope Hoisting

Scope hoisting (also called module concatenation) merges the closures of multiple ESM modules into a single flat scope. This exposes cross-module dead code to the minifier in a single pass and removes the per-module wrapper overhead.

In Webpack 5, scope hoisting is on by default in production mode. Verify it is not disabled by accident:

// webpack.config.js
module.exports = {
  mode: "production",
  optimization: {
    // Both must be true for DCE to reach across module boundaries
    concatenateModules: true, // scope hoisting
    usedExports: true,        // mark unused exports with /* unused harmony export */
  },
};

concatenateModules is silently disabled for any module that uses eval(), new Function(string), or module.hot. Audit third-party dependencies for these patterns if scope hoisting appears inactive.

In Rollup, scope hoisting is the default and cannot be disabled without switching to output.preserveModules: true. The Rollup DCE pass is therefore more aggressive than Webpack’s by design.


Step 3 — Route Bundlers to the ESM Artifact via Conditional Exports

Publishing both ESM and CJS introduces a critical risk: if a consumer’s bundler resolves the CJS artifact, static export tracking is impossible and tree-shaking is disabled for your entire package. The exports field controls which artifact each environment receives.

Conditional exports routing diagram A package.json exports field routes import() consumers to the ESM build (enabling tree-shaking) and require() consumers to the CJS build (no tree-shaking). Bundlers that respect the exports field receive ESM; older tooling falls back to CJS. "exports" package.json import() Webpack / Rollup / Vite require() Node.js / Jest / old tools dist/esm/index.js tree-shakeable dist/cjs/index.cjs no tree-shaking "import" "require"

The correct package.json configuration always places "types" first and "default" last within each condition so TypeScript and bundlers resolve in priority order:

{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/esm/index.d.ts",
        "default": "./dist/esm/index.js"
      },
      "require": {
        "types": "./dist/cjs/index.d.ts",
        "default": "./dist/cjs/index.cjs"
      }
    }
  }
}

Omitting "types" inside the condition forces TypeScript to fall back to the root types or typings field, which may point to the wrong declaration file when the ESM and CJS declarations diverge.

In Vite, the default resolve.conditions list already prefers import over require. If you have overridden it, restore the priority order:

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    // Order matters: first match wins
    conditions: ["import", "module", "browser", "default"],
  },
});

TypeScript’s moduleResolution: "bundler" (available since TypeScript 5.0) strictly follows the exports map and ignores legacy main/module fallbacks, eliminating one common source of accidental CJS resolution.

HAZARD PREVENTION — Omitting "types" inside conditional export conditions

Error: TypeScript resolves the wrong .d.ts for import vs require, producing type mismatches or Cannot find module errors even though the runtime artifact is correct.

Root cause: Without a "types" key inside the "import" block, TypeScript uses the root "types" field, which typically points at the CJS declaration file.

Fix: Add "types" as the first key inside both "import" and "require" conditions, pointing at the matching declaration file for each format.


Step 4 — Strip Environment Variables at Build Time

Feature flags evaluated at build time let minifiers eliminate entire dead branches. The trick is to replace process.env.NODE_ENV and import.meta.env references with string literals before minification runs, so the compressor can evaluate if ("production" === "production") as always-true and prune the else branch.

With @rollup/plugin-replace:

import replace from "@rollup/plugin-replace";

// rollup.config.ts (plugin array)
replace({
  "process.env.NODE_ENV": JSON.stringify("production"),
  preventAssignment: true, // prevents replacing the left side of assignments
}),

With esbuild’s define option (raw text substitution — values must be valid JS literals, so strings need explicit quotes):

{
  "define": {
    "process.env.NODE_ENV": "\"production\"",
    "import.meta.env.DEBUG": "false"
  },
  "minify": true
}

Terser’s compress options apply a second pass over already-bundled output. Use pure_funcs to mark specific function calls as side-effect-free and drop_console to eliminate all console statements:

// terser.config.js
module.exports = {
  compress: {
    drop_console: true,
    passes: 2,          // 2–3 passes; more yield diminishing returns
    pure_funcs: ["Math.floor", "invariant"],
  },
};

HAZARD PREVENTION — Environment variables survive into the published bundle

Symptom: grep -r "process.env.NODE_ENV" dist/ returns hits in the ESM artifact.

Root cause: The bundler step runs after Terser, or the replace plugin fires after Terser — so Terser never sees the literal value and cannot prune dead branches.

Fix: Apply replace/define in the bundler plugin pipeline before any minification step. In Webpack, use new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("production") }) rather than relying solely on mode: "production", which only sets it for Webpack’s own runtime.


Step 5 — Automate Bundle Auditing in CI

Size regressions are invisible without automated enforcement. size-limit integrates with GitHub Actions and blocks PRs that exceed per-export byte budgets.

# .github/workflows/bundle-size.yml
name: Bundle Size Audit
on: [pull_request]

jobs:
  size-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci          # lock-file governs the dependency graph
      - run: npx size-limit

.size-limit.json specifies per-export budgets:

[
  {
    "path": "dist/esm/index.js",
    "import": "{ parseSchema }",
    "limit": "3 kB"
  },
  {
    "path": "dist/esm/index.js",
    "import": "*",
    "limit": "12 kB"
  }
]

The "import" key tells size-limit to tree-shake to the named export before measuring — this catches regressions caused by a single export dragging in unexpectedly large transitive dependencies.

For visual investigation of what occupies bundle space, run webpack-bundle-analyzer in static mode (so CI does not hang waiting for a browser):

// webpack.config.js (CI environment)
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: "static",
      openAnalyzer: false,
      reportFilename: "bundle-report.html",
    }),
  ],
};

Upload bundle-report.html as a CI artifact and diff it visually when a size regression is flagged.


Tooling Validation

Run these commands after every configuration change to confirm elimination is actually happening rather than assuming it worked.

Check what the resolver sees:

npx publint

publint validates that exports, main, module, and types fields all point at existing files and that condition ordering is correct. A passing run confirms the CJS artifact is not accidentally the default resolution path.

Verify TypeScript resolves the right types for each condition:

npx are-the-types-wrong .

Sample passing output:

✓  ESM types resolve correctly (dist/esm/index.d.ts)
✓  CJS types resolve correctly (dist/cjs/index.d.ts)
✓  No resolution fallback to root "types" field

Confirm unused exports are marked:

In Webpack, set optimization.usedExports: true and inspect the output for /* unused harmony export myFunction */ comments. Their presence confirms Webpack’s DCE pass ran. Their absence in production mode means something (CJS interop, dynamic require, eval) broke scope tracking.

Measure actual gzip size of each export in isolation:

npx size-limit --why

The --why flag runs a full Webpack build instrumented with webpack-bundle-analyzer and prints the per-module contribution to the output. Any module over 1 kB that you did not expect to be included is a candidate for annotation or removal from the exports surface.


Compatibility Matrix

Feature Webpack 5 Rollup 3+ esbuild 0.17+ Vite 5 (Rollup)
/*#__PURE__*/ annotations Yes Yes Yes Yes (via Rollup)
Scope hoisting Yes (concatenateModules) Yes (default) Yes (default) Yes (default)
moduleSideEffects: false Partial (per sideEffects in pkg) Full N/A (use --tree-shaking=true) Full (via Rollup)
process.env replacement DefinePlugin @rollup/plugin-replace --define flag define in config
Conditional exports routing Yes (≥ Webpack 5) Yes Yes Yes
moduleResolution: "bundler" (TS 5+) Yes Yes Yes Yes
Per-export size budgets size-limit plugin size-limit plugin size-limit plugin size-limit plugin
--pure named-function flag No No Yes No

Node.js 18 is the minimum for native ESM support without --experimental-vm-modules in tests. Node.js 20 LTS is recommended for Jest ESM support with --experimental-vm-modules stable enough for CI.


Hazard Call-Outs

HAZARD PREVENTIONmoduleSideEffects: false drops legitimate CSS and polyfill imports

Symptom: Styles or global polyfills vanish in production builds after enabling moduleSideEffects: false in Rollup.

Root cause: Setting moduleSideEffects: false globally tells Rollup that every module is side-effect-free, including those that inject CSS into the DOM or patch globalThis.

Fix: Do not use moduleSideEffects: false globally. Instead, set "sideEffects": ["./src/styles.css", "./src/polyfills.js"] in package.json and leave moduleSideEffects at its default (true or "no-external").

HAZARD PREVENTION — Scope hoisting silently disabled by a third-party eval()

Symptom: Webpack’s bundle is unexpectedly large and /* harmony export */ comments are absent from output.

Root cause: A transitive dependency uses eval() or new Function(string). Webpack disables concatenateModules for all modules in that module’s chunk.

Fix: Run npx webpack --stats-preset verbose 2>&1 | grep "ModuleConcatenationPlugin" to see which modules prevented concatenation, then either exclude that dependency from scope hoisting via optimization.concatenateModules noParse rules or replace the dependency.

HAZARD PREVENTIONsize-limit measures the wrong artifact

Symptom: size-limit reports a small size but the real consumer bundle is large.

Root cause: The path in .size-limit.json points at the CJS artifact, which size-limit bundles with its own Webpack pass in CJS mode — disabling tree-shaking for the measurement itself.

Fix: Always point path at the ESM artifact and set "import": "{ namedExport }" to tree-shake before measuring.


Why an Export Survives: The Retention Chain

Dead-code elimination is often described as “removing what is not used”, which is backwards. Bundlers keep what they cannot prove is unused, so every retained byte is the end of a chain of failed proofs. Reading output that is larger than expected means walking that chain backwards until you find the link where the proof failed.

Four reasons a bundler retains code Code is retained when it is reachable from the entry point, when the module may have side effects, when a construct such as a getter or a class static block is opaque to the analyser, or when a dynamic reference cannot be resolved statically. A retained export is a proof the bundler could not complete 1. genuinely reachable something the entry imports uses it the only legitimate reason fix: change the consumer, not the build 2. possible side effect module-level work the analyser sees a call, an assignment, a prototype patch fix: sideEffects field, or PURE annotation 3. opaque construct getters, class static blocks, decorators evaluation order cannot be proven safe fix: move the work into a function body 4. dynamic reference obj[name], re-export loops, require() the whole namespace must be kept fix: replace with explicit named access

Category three is the one that most often surprises library authors, because the code looks perfectly static. A class with a static initialiser block runs code at definition time; a module-level Object.defineProperty with a getter is a call the analyser must assume observable; a decorator applied at module scope is a function invocation. None of these are removable without changing observable behaviour, so all of them anchor their entire module.

// retained: the static block runs when the class is defined
export class Registry {
  static instances = new Map<string, Registry>();
  static { Registry.instances.set("default", new Registry()); }
}

// removable: the same work, deferred until someone calls it
export class Registry {
  static instances = new Map<string, Registry>();
  static ensureDefault(): void {
    if (!Registry.instances.has("default")) Registry.instances.set("default", new Registry());
  }
}

The second version is fully eliminable when unused, because nothing runs at definition time. The behavioural difference — the default instance now appears on first call rather than on import — is usually acceptable and occasionally is the better design anyway.

Category four rewards a specific habit: never index into your own exports. A plugin lookup written as handlers[type] forces every handler to be retained, because the analyser cannot know which key will be requested. A switch with explicit imports, or a lazy import() per branch, gives the bundler the information it needs and produces a smaller graph for every consumer who uses one handler.

Reading the Evidence: Bundle Analysis Without Guesswork

Every claim above is testable, and the fastest loop is a fixture consumer that imports exactly one symbol, bundled with the same tool a real consumer would use. What comes out is a direct measurement of how eliminable your package is.

mkdir -p probe && cat > probe/one-symbol.ts <<'TS'
import { formatDate } from "@scope/my-library";
console.log(formatDate(new Date()));
TS

npx esbuild probe/one-symbol.ts --bundle --format=esm --minify \
  --metafile=probe/meta.json --outfile=probe/out.mjs

node -e '
  const m = require("./probe/meta.json");
  const out = Object.values(m.outputs)[0];
  const rows = Object.entries(out.inputs)
    .map(([file, v]) => [file, v.bytesInOutput])
    .filter(([, b]) => b > 0)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10);
  console.log("total:", out.bytes, "bytes");
  for (const [file, bytes] of rows) console.log(String(bytes).padStart(7), file);
'
total: 4187 bytes
   2104 node_modules/@scope/my-library/dist/format.mjs
   1290 node_modules/@scope/my-library/dist/index.mjs
    612 node_modules/@scope/my-library/dist/locale-data.mjs
    181 probe/one-symbol.ts

That output is a hypothesis generator. format.mjs is expected — it is what was imported. index.mjs at 1.3 kB means the entry module itself carries retained code, which for a pure re-export barrel should be close to zero. locale-data.mjs appearing at all means something in the retained chain references it, and finding what is the next question the metafile answers:

node -e '
  const m = require("./probe/meta.json");
  for (const [file, info] of Object.entries(m.inputs)) {
    if (!file.includes("locale-data")) continue;
    console.log(file, "imported by:");
  }
  for (const [file, info] of Object.entries(m.inputs))
    for (const imp of info.imports ?? [])
      if (imp.path.includes("locale-data")) console.log("  ", file);
'

Running this probe for three or four of your most-imported symbols, and recording the numbers in the repository, converts bundle size from an occasional panic into a tracked metric. The absolute values matter less than the deltas: a pull request that adds 300 bytes to the single-symbol probe has added a retention edge somewhere, and the metafile says exactly where.

One caveat about tooling parity. esbuild is the fastest way to run this loop, but it is not the tool most consumers use, and its analysis differs from Rollup’s and webpack’s in the edge cases catalogued in Comparing Bundler Tree-Shaking Output. Use esbuild for the fast inner loop while developing, and confirm with the bundler your consumers actually run before claiming a size improvement in a changelog.


What a Pure Annotation Changes

The annotation does not remove anything by itself. It converts a call the analyser must assume observable into one it is permitted to drop when the result is unused.

Effect of a pure annotation Without an annotation, a module-level call is assumed to have side effects and is kept along with everything it references. With the annotation, the call is dropped when nothing uses its result, and the functions it would have called become unreachable too. The annotation grants permission, it does not perform removal without the annotation const t = buildTable(); call is assumed observable buildTable and its data are kept even when t is never read with the annotation const t = /*#__PURE__*/ buildTable(); call may be dropped if unused buildTable becomes unreachable and is eliminated in turn

Because the annotation is an assertion rather than a check, an incorrect one silently removes real behaviour — which is why it belongs on calls you have personally verified to be free of side effects, not applied broadly to quieten a size report.


Pages in This Section



Tree-Shaking & Bundle Optimization