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.


Pages in This Section



Tree-Shaking & Bundle Optimization