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.
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.
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.
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
-
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. -
Why Getters and Class Statics Block Elimination — the constructs that run at definition time and anchor their whole module.
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.