Tree-Shaking Mechanics and Dead Code Elimination
Tree-shaking is a compile-time optimization that statically analyzes the module graph to prune unreachable exports, reducing both network transfer size and JavaScript parse/compile overhead. Unlike runtime lazy loading, it operates during the bundling phase by constructing Abstract Syntax Trees (ASTs), tracing identifier usage, and eliminating dead branches before code generation. This guide details the static-analysis mechanics, side-effect management, and validation pipelines required for deterministic dead code elimination across modern toolchains. For the underlying graph model and resolution algorithm, see Core Concepts of Modern Bundling before tuning any elimination passes.
The single most important thing to internalize: tree-shaking is conservative by default. A bundler will retain code whenever it cannot statically prove the code is unused and side-effect-free. Almost every “tree-shaking doesn’t work” report traces back to a module the analyzer was forced to keep — a sideEffects flag left at its default, a barrel re-export, a CommonJS interop wrapper, or a missing /*#__PURE__*/ annotation. The work is mostly about removing that uncertainty.
Prerequisites
Tree-shaking only activates during production builds where minification and the dead-code-elimination pass run together. Dev-server wrappers and HMR proxies inject runtime code that masks true bundle composition, so always reproduce against a production build. Pin the toolchain so behavior is deterministic:
- Rollup 4.x (the
treeshakeoption object below is the v4 shape). - Vite 5.x or 6.x, which drives Rollup for the production build and exposes
build.rollupOptions.treeshake. - esbuild 0.25.x, Node 20+. esbuild enables tree-shaking automatically when
--bundleis set; the flag forces it on for non-bundled transforms.
A working knowledge of ESM static binding is assumed. CommonJS interop is the most common reason elimination silently fails — see Understanding ESM vs CommonJS in Modern Bundlers for why require() defeats static analysis.
Static Analysis Foundations and Module Graph Traversal
Modern bundlers rely on deterministic AST traversal to map export dependencies. The process parses entry points into syntax trees, resolves import specifiers, and constructs a directed acyclic graph of module relationships. The analyzer then performs scope analysis to track identifier lifetimes, marking exports that have no downstream consumer for removal.
Three properties must hold for an export to be safely dropped: it must be statically referenced (or unreferenced) via ESM import/export syntax, the module containing it must be declared free of side effects, and any call that produces it must itself be side-effect-free. Violate any one and the bundler keeps the code. ESM guarantees static binding at parse time; CommonJS require() is evaluated at runtime and cannot be traversed safely, which is why CJS dependencies are the usual culprit behind retained dead code.
Core mechanics
- Parse entry points into AST nodes (
acornin Rollup,es-module-lexerfor fast import scanning, Go-native parsing in esbuild). - Resolve and graph — map each
import { x }declaration to its export definition, building the DAG. - Scope-analyze — track which bindings are live by walking references from the roots downward.
- Mark and prune — exports with no live reference, in modules proven pure, are deleted before code generation.
Configuration & CLI reference
Tree-shaking must be explicitly enabled or verified in production builds. External dependencies often declare implicit side effects; relaxing that assumption lets the pruner operate aggressively. Every block below is complete and runnable.
Rollup
// rollup.config.js — Rollup 4.x
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
export default {
input: 'src/index.js',
output: { dir: 'dist', format: 'esm', sourcemap: true },
plugins: [resolve(), terser()],
treeshake: {
moduleSideEffects: 'no-external', // assume external packages are side-effect free
propertyReadSideEffects: false, // ignore object property access side effects
tryCatchDeoptimization: false, // do not deopt pruning inside try/catch
unknownGlobalSideEffects: false // treat reads of unknown globals as pure
}
};
Run it with npx rollup -c. The moduleSideEffects: 'no-external' setting is the most impactful knob for vendor-heavy apps: it tells Rollup that any module under node_modules may be dropped if unreferenced, regardless of that package’s own sideEffects field.
Vite
// vite.config.ts — Vite 5.x / 6.x (Rollup 4.x under the hood)
import { defineConfig } from 'vite';
export default defineConfig({
build: {
minify: 'esbuild', // or 'terser' for deeper passes
reportCompressedSize: true, // print gzip sizes per chunk
rollupOptions: {
treeshake: {
moduleSideEffects: 'no-external',
propertyReadSideEffects: false
}
}
},
resolve: { dedupe: ['react', 'react-dom'] } // prevent duplicate module retention
});
Build with vite build. Vite delegates the production tree-shake entirely to Rollup, so the treeshake object has the same semantics as the standalone config above.
esbuild
# esbuild 0.25.x, Node 20+
# tree-shaking is implicit with --bundle; the flag forces strict mode,
# and --define lets dead conditional branches be pruned.
esbuild src/index.ts \
--bundle \
--tree-shaking=true \
--minify \
--define:process.env.NODE_ENV=\"production\" \
--metafile=meta.json \
--outfile=dist/bundle.js
The --metafile=meta.json output is the machine-readable record of which modules survived; feed it to esbuild --analyze or the CI script below.
Declaring purity in a publishable package
{
"name": "@scope/ui-kit",
"type": "module",
"sideEffects": false,
"exports": {
"./button": "./src/button/index.js",
"./modal": "./src/modal/index.js"
}
}
// Annotate factory calls the analyzer cannot prove pure on its own.
export const logger = /*#__PURE__*/ createLogger({ prefix: 'ui-kit' });
If your package legitimately needs some modules to execute on import (CSS injection, polyfill registration), enumerate only those instead of false:
{ "sideEffects": ["**/*.css", "./src/register-polyfills.js"] }
Numbered workflow
- Reproduce against a production build. Run
vite build,rollup -c, or the esbuild command above. Never diagnose tree-shaking from the dev server. - Generate a retention report. Emit an esbuild
--metafile, or addrollup-plugin-visualizerand open the treemap. Record the baseline byte size. - Audit side effects. For each heavy retained module, check whether its
package.jsondeclaressideEffects. Add a root-level override for dependencies that omit it. - Apply
/*#__PURE__*/to factory and IIFE call sites in your own code whose results may go unused. - Inject build constants (
process.env.NODE_ENV,__DEV__) so dev-only branches collapse toif (false)and get stripped before traversal. - Rebuild and diff. Re-run the build, regenerate the report, and confirm the previously monolithic vendor node has fragmented. Target a measurable byte delta.
- Lock it in CI. Assert a bundle budget so a future dependency bump cannot silently reintroduce the retained code.
Verification commands:
vite build && du -sh dist # inspect total output size
npx source-map-explorer dist/assets/*.js # confirm which modules remain
node scripts/check-bundle.js # CI threshold assertion (see below)
Debugging & failure modes
CommonJS interop blocks static analysis
Rollup wraps CJS modules in synthetic ESM wrappers. If a package mutates module.exports dynamically (exports[name] = fn), Rollup cannot determine which exports are used and retains the entire object. Fix by importing the ESM build of the dependency (check its exports map) or pre-bundling it with @rollup/plugin-commonjs configured with explicit namedExports.
Missing or over-broad sideEffects
If a dependency omits sideEffects: false, every file is assumed impure and skipped by the pruner. Over-broad globs like "src/**/*.js" are equally fatal. Override the field in your root package.json to scope it to only the files that truly execute on import.
Dynamic import() with a variable path
import(`./modules/${name}.js`) cannot be resolved statically, so the bundler either bundles every match or fails to isolate the chunk. Replace it with an explicit lookup map of static import() calls.
Barrel files re-exporting everything
A barrel index.ts that re-exports an entire directory creates an implicit dependency chain. Importing one symbol pulls the barrel, which references every sibling, defeating elimination. This pattern is common enough to warrant its own treatment — see Eliminating Barrel File Side Effects in Tree-Shaking.
Verbose logging
Set logLevel: 'debug' in Rollup or --log-level=debug in esbuild to print module-inclusion decisions. Exports that survive despite zero consumers appear with their originating module path, which points straight at the impure module.
Performance impact & measurement
Correctly scoped barrel elimination typically reduces initial bundle weight by 15–30%. Disabling propertyReadSideEffects yields a further 4–8% in utility-heavy codebases by permitting removal of unused object property reads. Stripping dev-only validation, prop-type checks, and hot-reload hooks via constant injection usually removes 8–22 KB per route-level payload. When combined with the chunk boundaries described in Code Splitting Strategies for Large Applications, these reductions compound across lazy-loaded routes.
Measure, don’t guess. The deterministic loop is: generate a metafile or visualizer report, filter retained modules by size and import depth, trace each heavy module to its side-effect source, then apply an upstream patch or a local sideEffects override. A CI bundle budget catches better than 90% of regressions before merge.
// scripts/check-bundle.js — Node 20+, run after vite build
import { readFileSync } from 'node:fs';
const stats = JSON.parse(readFileSync('dist/stats.json', 'utf-8'));
const MAX_KB = 150;
const FORBIDDEN = ['lodash', 'moment/locale'];
let total = 0;
const seen = new Set();
(function walk(node) {
if (node.name) seen.add(node.name);
if (node.size) total += node.size;
node.children?.forEach(walk);
})(stats);
const kb = total / 1024;
const hits = FORBIDDEN.filter((m) => [...seen].some((n) => n.includes(m)));
if (kb > MAX_KB) { console.error(`Bundle ${kb.toFixed(1)}KB > ${MAX_KB}KB`); process.exit(1); }
if (hits.length) { console.error(`Tree-shaking regression: ${hits.join(', ')}`); process.exit(1); }
console.log(`Bundle OK: ${kb.toFixed(1)}KB, no forbidden modules`);
Compatibility matrix
| Tool / version | Tree-shaking default | Key knob | Node | Known conflict |
|---|---|---|---|---|
| Rollup 4.x | On for ESM output | treeshake.moduleSideEffects |
18+ | CJS deps need @rollup/plugin-commonjs |
| Vite 5.x / 6.x | On in vite build |
build.rollupOptions.treeshake |
18+ / 20+ | dev server never tree-shakes |
| esbuild 0.25.x | On with --bundle |
--tree-shaking, --define |
18+ | shallower than Rollup; ignores some sideEffects edge cases |
| Turbopack (Next 15) | On in production | next.config (limited) |
18.18+ | partial sideEffects honoring |
| webpack 5.x | On in mode: production |
optimization.usedExports, sideEffects |
18+ | requires mode: production to engage |
Related
- Core Concepts of Modern Bundling — the dependency-graph and resolution model that tree-shaking builds on.
- Debugging Tree-Shaking Failures with rollup-plugin-visualizer — read the treemap and correlate retained nodes with AST logs.
- Eliminating Barrel File Side Effects in Tree-Shaking — why
index.tsre-exports defeat elimination and how to fix them. - Understanding ESM vs CommonJS in Modern Bundlers — why
require()interop blocks static analysis. - Code Splitting Strategies for Large Applications — pair elimination with chunk boundaries for compounding wins.