Advanced Dead Code Elimination Techniques
Beyond basic tree-shaking: AST mechanics, PURE annotations, environment variable stripping, dual-package conditional exports, and automated bundle auditing in CI. Covers Webpack, Rollup, esbuild, and Vite.
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.
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 conditionsError: TypeScript resolves the wrong
.d.tsforimportvsrequire, producing type mismatches orCannot find moduleerrors 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
replaceplugin fires after Terser — so Terser never sees the literal value and cannot prune dead branches.Fix: Apply
replace/definein the bundler plugin pipeline before any minification step. In Webpack, usenew webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("production") })rather than relying solely onmode: "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 PREVENTION —
moduleSideEffects: falsedrops legitimate CSS and polyfill importsSymptom: Styles or global polyfills vanish in production builds after enabling
moduleSideEffects: falsein Rollup.Root cause: Setting
moduleSideEffects: falseglobally tells Rollup that every module is side-effect-free, including those that inject CSS into the DOM or patchglobalThis.Fix: Do not use
moduleSideEffects: falseglobally. Instead, set"sideEffects": ["./src/styles.css", "./src/polyfills.js"]inpackage.jsonand leavemoduleSideEffectsat its default (trueor"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()ornew Function(string). Webpack disablesconcatenateModulesfor 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 viaoptimization.concatenateModulesnoParse rules or replace the dependency.
HAZARD PREVENTION —
size-limitmeasures the wrong artifactSymptom:
size-limitreports a small size but the real consumer bundle is large.Root cause: The
pathin.size-limit.jsonpoints at the CJS artifact, whichsize-limitbundles with its own Webpack pass in CJS mode — disabling tree-shaking for the measurement itself.Fix: Always point
pathat the ESM artifact and set"import": "{ namedExport }"to tree-shake before measuring.
Pages in This Section
- Why Barrel Files Break Tree-Shaking in Webpack — how index re-exports defeat Webpack’s export tracking and how to restructure your source to fix it
- Pure Annotations for Dead-Code Elimination — how
/*#__PURE__*/and/*#__NO_SIDE_EFFECTS__*/annotations let bundlers drop unused factory calls and class definitions from library output.
Related
- Implementing the
sideEffectsFlag Correctly — the prerequisite configuration step that tells bundlers which files are safe to omit entirely. - Eliminating Barrel File Anti-Patterns — restructuring module boundaries so static analysis can trace individual exports rather than entire index files.
- Optimizing Bundle Size for Frontend Libraries — measuring real consumer impact with Bundlephobia and
webpack-bundle-analyzerafter DCE configuration is complete. - Mastering the
package.jsonexportsField — the authoritative reference for condition ordering, subpath patterns, and TypeScript type resolution in dual-format packages. - Navigating the Dual-Package Hazard — what happens when both ESM and CJS copies of your package are instantiated simultaneously, and how to prevent it.