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 problem exists because JavaScript was never designed to be shipped as an application binary. A dependency author publishes one artifact that must serve every possible consumer, so it exports everything it can conceivably offer. A date library exports every locale; an icon set exports every glyph; a utility package exports hundreds of helpers where a given application imports four. Without a pruning pass, the byte cost of a dependency is the cost of its entire public surface, not the cost of what you actually call. On a route that imports a single formatting function from a 70 KB utility package, the difference between shipping 70 KB and shipping 800 bytes is entirely down to whether the bundler could prove the other 69 KB was dead. That proof is what tree-shaking produces, and the proof is fragile: it can be defeated by a single line in a package.json you do not control.

Tree-shaking sits late in the pipeline but depends on decisions made early. Resolution and graph construction happen first, transforms (TypeScript stripping, JSX, Babel) run next, and only then does the analyzer walk the finished graph to decide what is reachable. The minifier runs last and physically deletes the statements the analyzer marked dead. This ordering matters for diagnosis: if a transform rewrites your ESM import into a CommonJS require before the analyzer runs — which a mis-scoped Babel preset will happily do — the analyzer sees dynamic member access and gives up, and no amount of sideEffects tuning downstream will recover it. The failure was upstream of the pass you are trying to configure.

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. This is not timidity; it is correctness. Deleting a module that registers a global polyfill, patches a prototype, or installs a service worker on import would silently break the application at runtime, and a bundler that did so could not be trusted. So the analyzer inverts the burden of proof: code stays unless purity is provable. 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, one module at a time, and then locking the result so it cannot silently regress.

Dead code elimination flow from source graph to pruned output The sideEffects flag and PURE annotations gate the AST so the bundler can prune unreferenced modules from the final graph. Source module graph import { a } from 'lib' a, b, c exported b, c unreferenced sideEffects: false declares module pure /*#__PURE__*/ drops unused calls AST traversal scope analysis mark reachable Pruned a only Failure path: any unprovable side effect forces retention barrel re-export, CJS interop wrapper, dynamic import(), default sideEffects → whole module kept b and c survive into the bundle Verification path: metafile / visualizer maps every retained module to its import chain esbuild --metafile, rollup-plugin-visualizer treemap, CI bundle budget confirms b and c are gone
Figure: the sideEffects flag and PURE annotations gate AST traversal; anything the analyzer cannot prove pure falls through to the retention path.

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 treeshake option 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 --bundle is set; the flag forces it on for non-bundled transforms.
Tree-shaking only runs in production builds The dev server injects HMR wrappers and never tree-shakes, so elimination must be diagnosed against a production build with Rollup 4, Vite 5/6 or esbuild 0.25. dev serverHMR wrappers · never tree-shakes production buildRollup 4 · Vite 5/6 · esbuild 0.25 Never diagnose tree-shaking from the dev server
Figure: the dev server masks true bundle composition — always reproduce against a production build.

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.

The reason the toolchain must be pinned is that tree-shaking heuristics change between minor versions. Rollup has tightened its purity inference several times across the 3.x-to-4.x line; esbuild periodically adjusts which patterns it recognizes as pure; Vite’s default treeshake preset tracks whatever Rollup it bundles. A build that shrinks 40 KB on one machine and not another is almost always two different resolved versions of the same tool, not a configuration difference. Lock the versions in package-lock.json or pnpm-lock.yaml, and record the exact tool version alongside any bundle-size number you report, because the number is meaningless without it. The same caution applies to your minifier: terser and esbuild’s minifier honor /*#__PURE__*/ differently at their edges, and swapping one for the other can move a module in or out of the bundle without a single source change.

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.

The reason ESM is analyzable and CommonJS is not comes down to when the binding is resolved. An ESM import { format } from 'date-fns' names the exact binding it wants at parse time — the specifier and the imported name are both literals the analyzer can read without executing anything. A CommonJS const df = require('date-fns'); df[fnName]() resolves the module to a runtime object and then indexes into it with a value that may only exist at runtime. The analyzer cannot know which properties of that object are touched, so it must assume all of them are, and every exported function stays. This is not a Rollup limitation that a future version fixes; it is a consequence of the language semantics, and it is why the correct fix is almost always to import the package’s ESM entry point rather than to configure the pruner harder.

Side-effect freedom is the second condition and the one authors get wrong most often. A module has a side effect if evaluating it top-to-bottom changes anything observable outside the module: assigning to window, patching Array.prototype, calling customElements.define, importing a CSS file, or invoking any function whose body the analyzer cannot see. The analyzer cannot inspect the runtime behavior of an arbitrary function call, so a bare top-level call — initSentry() at module scope — is treated as a side effect and pins the module in the graph even if nothing imports anything from it. This is why the sideEffects field exists: it is the author asserting, out of band, that evaluating the module is safe to skip when none of its exports are used.

The third condition, per-call purity, is finer-grained than the module flag. Even inside a module declared side-effect-free, a specific top-level call like const registry = buildRegistry() is opaque — the analyzer cannot see whether buildRegistry mutates global state, so it retains the assignment and everything it transitively pulls in. The /*#__PURE__*/ annotation is the escape hatch: it is a promise to the analyzer that this specific call has no side effects, so if registry is never referenced, the call and its dependencies can be dropped. Minifiers emit these annotations automatically for a handful of known-pure constructors; for your own factory functions you must write them by hand.

Four analysis stages and the three drop conditions The analyzer parses to an AST, resolves and graphs imports, scope-analyzes to mark live bindings, then marks and prunes; an export is dropped only if it is statically bound via ESM, its module is declared side-effect-free, and any producing call is pure. 1 parse ASTacorn / native 2 resolve + graphbuild DAG 3 scope-analyzemark live 4 mark + prunedelete dead Drop only if: ESM-bound AND module pure AND producing call pure require() breaks the first condition — CJS defeats the whole pass.
Figure: four stages, three conditions — a single CJS require() fails condition one and retains the module.

Core mechanics

  1. Parse entry points into AST nodes (acorn in Rollup, es-module-lexer for fast import scanning, Go-native parsing in esbuild).
  2. Resolve and graph — map each import { x } declaration to its export definition, building the DAG.
  3. Scope-analyze — track which bindings are live by walking references from the roots downward.
  4. Mark and prune — exports with no live reference, in modules proven pure, are deleted before code generation.

How the mark-and-sweep pass actually works under the hood

The elimination pass is a graph reachability problem dressed up as a compiler pass. The roots are the entry points’ top-level statements plus anything with a proven side effect. From those roots the analyzer performs a marking traversal: for each live binding it visits the declaration that produces it, marks that declaration live, and recurses into every binding the declaration references. A statement that is never reached by this traversal, and whose evaluation the analyzer has proven cannot be observed elsewhere, is swept. This is the same mark-and-sweep shape as a garbage collector, run over source rather than heap objects, which is why the mental model of “roots keep references alive” transfers directly.

Two subtleties make the real implementation harder than the textbook version. First, purity is not a property of a module in isolation — it is a property of a module given which of its exports are used. A file can be side-effect-free when you import formatDate from it and side-effect-bearing when you import nothing, because importing nothing means the top-level initLocaleData() call has no consumer to justify it yet still runs. Rollup models this by tracking side effects at statement granularity, not file granularity, which is why it can keep one exported function from a module and drop three others in the same file. esbuild is coarser: it reasons closer to the file level and will more readily keep a whole module that Rollup would have split, which is the concrete meaning of “esbuild tree-shakes shallower.”

Second, ordering interacts with caching. Rollup builds the full graph before it prunes, so its decisions are global and it can prove a module dead even when the evidence is spread across several files. esbuild interleaves parsing, linking, and elimination in a single fast pass over the graph and caches parsed ASTs aggressively between incremental builds, which is what makes it an order of magnitude faster and also what makes its analysis local rather than whole-program. Neither approach re-derives purity from scratch on every keystroke — both cache the parsed AST and the per-module side-effect verdict, invalidating only the modules whose source changed. That cache is why the first production build is slow and the second is fast, and why a stale cache occasionally explains a module that “should” have been dropped reappearing: bust the cache before you trust a null result.

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.

The key tree-shaking knob per tool Rollup exposes a treeshake object with moduleSideEffects no-external as the highest-impact knob; Vite delegates entirely to Rollup via build.rollupOptions.treeshake; esbuild enables it implicitly with --bundle and --define prunes dead branches. RollupmoduleSideEffects: 'no-external' Vitedelegates to Rollup treeshake esbuild--bundle + --define no-external is the highest-impact knob for vendor-heavy apps
Figure: moduleSideEffects: 'no-external' lets Rollup drop unreferenced node_modules regardless of a package's own flag.

Rollup

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.

Understand the risk before you enable each of these. moduleSideEffects: 'no-external' is safe for the overwhelming majority of libraries but will break the rare dependency that genuinely relies on an import-time side effect it forgot to declare — a CSS-in-JS runtime that self-registers, a polyfill that patches globals. If a feature disappears in production but works in dev after you set this, that dependency is your suspect; add it back to the side-effect set with an explicit moduleSideEffects function that returns true for its path. propertyReadSideEffects: false asserts that reading a property never triggers a getter with observable effects; this is true for plain data objects and false for the handful of libraries that expose reactive proxies, so audit for Proxy and Object.defineProperty getters before trusting it. tryCatchDeoptimization: false stops Rollup from conservatively retaining code merely because it sits inside a try block — a real win for defensive libraries, but only correct if none of those catch blocks are load-bearing. unknownGlobalSideEffects: false tells Rollup that reading a global you did not declare is pure; leave it off if your code probes for feature-detection globals whose mere access matters.

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. The resolve.dedupe entry is easy to overlook and often the difference between a clean bundle and a bloated one: when two versions of react resolve through different paths, Rollup treats them as distinct modules and cannot deduplicate their exports, so both survive. Deduplication is not tree-shaking, but a duplicated dependency defeats tree-shaking’s whole purpose by shipping the same code twice, and the visualizer treemap will show two vendor nodes with the same name when it happens.

The minify choice interacts with elimination more than it appears to. Vite’s default esbuild minifier is fast and strips the dead statements Rollup marked, but it performs shallower cross-statement analysis than terser. For a size-critical production bundle, minify: 'terser' with compress.passes raised to 2 or 3 will occasionally find dead code that a single esbuild pass leaves behind, at the cost of a slower build. Measure the delta rather than assuming it; on many apps it is under a kilobyte and not worth the build-time regression, while on utility-heavy code it is meaningful.

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.

The --define:process.env.NODE_ENV=\"production\" flag does more work than its size suggests. Libraries guard development-only code — prop-type validation, invariant messages, warning helpers — behind if (process.env.NODE_ENV !== 'production'). With the constant defined, esbuild folds the condition to if (false) and the entire branch, plus any imports only that branch used, becomes provably dead and is swept. Without the define, process.env.NODE_ENV is an opaque runtime member access, the branch is live, and the development scaffolding of every library ships to users. This one substitution is frequently the single largest tree-shaking win in a React application, and it is invisible to sideEffects tuning because the dead code is inside a module you legitimately use.

The metafile is the only source of truth esbuild gives you, and it is worth reading directly rather than only through the visualizer. Each output entry lists its inputs map, and each input records the bytes it contributed after tree-shaking. A module you expected to vanish appearing with a non-zero byte count is the signal that some import chain still reaches it; the metafile’s imports array on the referencing module tells you which chain. Pipe it through jq to sort inputs by contributed bytes and the top of that list is your work queue.

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"] }

The array form is a whitelist of impurity: every path that matches keeps its import-time behavior, and everything else is assumed pure. Get the globs right and consumers get maximal pruning while your CSS still loads; get them wrong in either direction and you either strip a stylesheet or pin the whole package. Two failure modes recur. An over-broad glob like "src/**" is equivalent to sideEffects: false being absent — it marks everything impure and disables pruning across your package, which defeats the point of shipping ESM at all. An over-narrow list that forgets a file with a real side effect ships a package that works in your test harness and breaks in a consumer’s production build once that file gets shaken out, which is the worst kind of bug because it only appears downstream.

A concrete worked example makes the annotation rules land. Consider a package that exports a client factory whose construction is expensive and whose result may go unused on some routes:

// @scope/analytics/src/index.js — package source, ESM
// The factory call is opaque to the analyzer: without the annotation
// Rollup retains `client` and everything createClient() transitively imports,
// even on a page that never references analytics.
import { createClient } from './client-impl.js';

// PURE promises the call is side-effect-free, so if `client` is never
// imported by the app, the call and its dependency subtree are swept.
export const client = /*#__PURE__*/ createClient({
  endpoint: 'https://example.test/collect',
  batch: true,
});

// A named export with no top-level work is analyzable without annotation.
export function track(event) {
  return client.enqueue(event);
}

An application that imports only track will still retain client, because track references it — that is correct, not a leak. But an application that imports neither will drop both, and an application that imports some unrelated third export will also drop client and its entire dependency subtree, only because of the annotation. Remove the /*#__PURE__*/ and the factory call becomes an unremovable root, dragging the whole analytics stack into every consumer regardless of use. This is the mechanism behind most “why is this library in my bundle when I never call it” reports: an unannotated top-level factory call in the library’s entry module.

Numbered workflow

Seven-step tree-shaking workflow Reproduce in a production build, generate a retention report, audit side effects, annotate pure calls, inject build constants, rebuild and diff the report, then lock a bundle budget in CI. 1 prod buildbaseline 2 reportmetafile 3 auditsideEffects 4 PUREannotate 5 constantsdefine 6 rebuilddiff 7 CI budgetlock Measure at step 2 and step 6 — the byte delta is the proof.
Figure: the loop is measure → patch → re-measure; the CI budget at step 7 stops regressions reappearing.
  1. Reproduce against a production build. Run vite build, rollup -c, or the esbuild command above. Never diagnose tree-shaking from the dev server.
  2. Generate a retention report. Emit an esbuild --metafile, or add rollup-plugin-visualizer and open the treemap. Record the baseline byte size.
  3. Audit side effects. For each heavy retained module, check whether its package.json declares sideEffects. Add a root-level override for dependencies that omit it.
  4. Apply /*#__PURE__*/ to factory and IIFE call sites in your own code whose results may go unused.
  5. Inject build constants (process.env.NODE_ENV, __DEV__) so dev-only branches collapse to if (false) and get stripped before traversal.
  6. Rebuild and diff. Re-run the build, regenerate the report, and confirm the previously monolithic vendor node has fragmented. Target a measurable byte delta.
  7. 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

Four tree-shaking failure modes CommonJS interop blocks static analysis and retains the whole object; a missing or over-broad sideEffects field forces retention; a dynamic import with a variable path cannot be isolated; and a barrel file re-exporting a directory pulls every sibling. CommonJS interop dynamic module.exports → whole object kept; import the ESM build. missing/over-broad sideEffects every file assumed impure — scope the field to files that truly run on import. dynamic import(variable) can't be resolved statically — use an explicit map of static import() calls. barrel re-exports one import pulls the barrel, which pulls every sibling — see the barrel guide.
Figure: two "the analyzer can't prove purity" traps and two "the import can't be isolated" traps.

CommonJS interop blocks static analysis

Symptom: a single dependency accounts for a disproportionate slice of the treemap, and its node is a monolithic block that never fragments no matter what you configure. Root cause: Rollup wraps CJS modules in synthetic ESM wrappers so they can participate in the graph, but if a package mutates module.exports dynamically — exports[name] = fn in a loop, a re-exported require, a Object.defineProperty on the exports object — Rollup cannot statically determine which properties are read and must retain the entire object to stay correct. Fix: import the ESM build of the dependency; most modern packages ship one and select it through the exports map or a module field, so importing from the package root or its documented ESM subpath is usually enough. Where only a CJS build exists, pre-bundle it with @rollup/plugin-commonjs configured with explicit namedExports so the plugin can synthesize analyzable bindings. Confirm: rebuild, regenerate the treemap, and check that the single block has split into per-export leaves; if it has not, the import is still resolving to the CJS artifact and you can verify with --log-level=debug, which prints the resolved path per module.

Missing or over-broad sideEffects

Symptom: files you clearly do not use survive, and the retained set correlates with a whole package or a whole source directory rather than with specific exports. Root cause: if a dependency omits sideEffects entirely, the analyzer must assume every file in it runs meaningful code on import and skips pruning across the package; the mirror-image failure is an over-broad glob such as "src/**/*.js" in your own package, which asserts everything is impure and disables elimination just as thoroughly. Fix: add a scoped sideEffects override. For a dependency you do not control, you cannot edit its package.json, so set treeshake.moduleSideEffects: 'no-external' to override all of node_modules at once, or supply a function that returns false for the specific package path. For your own package, replace the broad glob with an exact list of the files that truly execute on import. Confirm: the retained byte count for that package drops in the metafile diff, and no runtime feature regresses — the second half matters, because an over-aggressive override is how you strip a stylesheet.

Dynamic import() with a variable path

Symptom: a lazy-loaded feature either drags in far more than the one module you expected, or the build warns that it cannot analyze a dynamic import and emits a catch-all chunk. Root cause: import(`./modules/${name}.js`) interpolates a value the analyzer cannot resolve at build time, so the bundler must assume any file matching the pattern could be requested and includes all of them in one chunk, or bails out entirely. Fix: replace the interpolated import with an explicit map of static import() calls, one literal specifier per branch, so each becomes its own analyzable, individually shakeable chunk:

// Static specifiers let the bundler split and shake each route chunk. — Vite 5.x / Rollup 4.x
const routes = {
  dashboard: () => import('./modules/dashboard.js'),
  settings: () => import('./modules/settings.js'),
  billing: () => import('./modules/billing.js'),
};
export const load = (name) => routes[name]();

Confirm: the build emits one chunk per entry in the map rather than a single combined chunk, visible in the Rollup output manifest or the esbuild metafile’s outputs list.

Barrel files re-exporting everything

Symptom: importing one small helper pulls a large, unrelated subtree into the bundle, and the treemap shows siblings of your import that you never named. Root cause: a barrel index.ts that re-exports an entire directory creates an implicit dependency chain — importing one symbol pulls the barrel module, and if the barrel or any sibling has an unprovable side effect, referencing the barrel keeps the lot. Fix: import from the deep path (import { x } from 'lib/x') to bypass the barrel, or ensure the barrel and every module it re-exports are provably side-effect-free so the analyzer can drop the unreferenced siblings. This pattern is common enough to warrant its own treatment — see Eliminating Barrel File Side Effects in Tree-Shaking. Confirm: the sibling modules disappear from the treemap after the deep import or the flag change; if they persist, one of them has a real side effect that the sideEffects audit above must catch.

Verbose logging

Symptom: you have applied every fix above and one module still survives with no obvious consumer, and staring at source is not revealing the chain. Root cause: some reference chain you have not spotted still reaches the module — often a transitive re-export or a sideEffects-pinned neighbor. Fix: 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 or the import that keeps it alive. Confirm: follow the printed path to the offending statement, apply the matching fix from the sections above, and re-run with debug logging until the module no longer appears in the inclusion list.

Performance impact & measurement

Three measurable tree-shaking wins Correctly scoped barrel elimination reduces initial bundle weight by 15 to 30 percent, disabling propertyReadSideEffects yields a further 4 to 8 percent, and constant injection strips 8 to 22 kilobytes of dev-only code per route. Typical reductions (measure, don't guess) barrel elimination 15–30% propertyReadSideEffects: false +4–8% constant injection 8–22 KB/route Compounds across lazy routes when paired with code splitting.
Figure: three independent levers whose savings compound across lazy-loaded routes.

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

Tree-shaking behaviour across five tools Rollup tree-shakes ESM output by default, Vite tree-shakes in vite build but never in the dev server, esbuild does it with --bundle but shallower than Rollup, Turbopack honors sideEffects partially, and Webpack needs mode production to engage. Rollup / Vitedeepest; Vite = prod only esbuild--bundle; shallower Turbopackpartial sideEffects Webpack 5needs mode: production + usedExports to engage
Figure: Rollup/Vite prune deepest; esbuild is faster but shallower; Webpack needs production mode to engage at all.
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

In-Depth Guides