Tree Shaking and Dead Code Elimination: Advanced Implementation and Diagnostics

This is a focused workflow within JavaScript bundle optimization and code splitting: how to systematically strip unreachable code until production ships less than 5% unused JavaScript.

Modern build pipelines lean on static analysis to remove unreachable code before deployment. Tree shaking and dead code elimination (DCE) operate at distinct phases: tree shaking resolves unused exports at the module-graph level, while DCE runs during minification to remove unreachable branches, unused variables, and side-effect-free calls. The actionable boundary is concrete — a production bundle with <5% unused code, vendor chunks under 150KB, and a downstream LCP under 2.5s. Hitting it requires precise configuration, explicit side-effect declarations, and a repeatable diagnostic loop rather than guesswork. This guide moves from baseline capture through root-cause isolation to validated fixes you can copy into a real pipeline.

Two-phase dead code removal Tree shaking prunes the module graph; the minifier removes dead branches at the AST level. Where unused code is removed ESM import graph static analysis drops unused exports sideEffects contract marks pure files enables pruning Minifier DCE AST control flow removes dead code Target: under 5% unused bytes per chunk, vendor under 150KB gzipped. A miss in either phase leaves dead weight downstream of LCP. Diagnose with stats.json plus the DevTools Coverage tab, then fix the dominant cause.

Problem Framing: When Unused Bytes Become a Metric Problem

Dead code is not an abstract cleanliness issue — it lands on the critical path. Every unused kilobyte still costs network transfer, parse, and compile time on the main thread, inflating Total Blocking Time and pushing the largest paint past the 2.5s boundary. A vendor chunk that carries 40% unused exports is effectively a long-task generator: the engine still parses every byte before it can decide the code is unreachable at runtime. The goal of this workflow is to make the unused fraction observable, attributable to a specific module, and reducible to under 5% — then to lock that result with a CI budget so regressions fail the pull request instead of the user's device.

Prerequisites: Versions, Packages, and Flags

Pin these before capturing a baseline, because tree-shaking behavior changed materially across major bundler versions:

  • Node 18.18+ (ESM resolution and package.json exports map support).
  • Webpack 5.x with mode: 'production', or Vite 5.x / Rollup 4.x.
  • Analysis tooling: webpack-bundle-analyzer 4.x or rollup-plugin-visualizer 5.x.
  • Minifier: Terser 5.x, or esbuild (bundled with Vite). The choice has real size and speed consequences covered in esbuild vs Terser for production minification.
  • A package.json you control, so you can set the sideEffects field accurately.

Static analysis only works on statically analyzable input. If your dependency chain mixes CommonJS and ESM, resolve that first — the trade-offs between the two formats are unpacked in modern module formats: ESM vs CommonJS.

1. Environment Setup: Force Static-Analyzable Inputs

Bundlers build deterministic dependency graphs exclusively through ESM import/export syntax. Unlike CommonJS require(), which resolves dynamically at runtime and hides dependency chains, ESM exports are statically analyzable. During compilation the bundler traverses the module graph, traces export usage across boundaries, and marks unreferenced nodes for pruning. This is strictly build-time and requires deterministic import paths; any runtime evaluation or dynamic property access (import * as lib from 'pkg'; lib[methodName]()) breaks analysis and forces the bundler to retain the whole module.

javascript
// FAILS analysis: dynamic property access keeps the entire module.
import * as utils from './utils';
const fn = utils[name];

// ENABLES analysis: named imports the bundler can resolve statically.
import { formatDate } from './utils';
// trade-off: named imports only help when './utils' is authored as ESM —
// against a CommonJS dependency this rewrite changes nothing, so verify the
// package ships an ESM build (a "module" or "exports" entry) first.

Confirm every internal entry point and first-party shared utility is authored as ESM, and that third-party packages resolve to their ESM build via resolve.mainFields (Webpack) or Vite's default module-first resolution.

2. Capture Baseline: Quantify the Unused Fraction

You cannot improve what you have not measured. Generate a build profile and compute the unused-byte delta per module before changing any configuration:

  1. Generate a detailed build profile: npx webpack --profile --json > stats.json.
  2. Parse the modules array and filter for entries where usedExports is false or contains null.
  3. Calculate the delta between size and usedSize — that delta is your unused payload.
  4. Cross-check at runtime with the Chrome DevTools Coverage tab (Ctrl+Shift+PShow Coverage) to capture code that loads but never executes on the initial route.

Record the per-module numbers. Threshold: flag any module carrying >10KB of unused exports for immediate attention, and any non-entry chunk above 20% unused bytes.

3. Isolate the Bottleneck: sideEffects and Manifest Contracts

Bundlers assume every file has side effects unless told otherwise. The sideEffects field in package.json is a strict contract with the build system. Setting it to false enables aggressive pruning across the dependency tree, but a blanket declaration risks stripping critical runtime behavior — CSS injection, polyfill registration, global state initialization.

json
{
  "name": "@my-org/ui-components",
  "version": "1.0.0",
  "sideEffects": [
    "*.css",
    "*.scss",
    "src/polyfills.js",
    "src/setupTests.js"
  ]
}

Use the array form rather than false whenever a package mutates global state or injects styles. Cross-reference each declaration against actual DOM mutations or global-scope writes, then run a staging deployment and watch the console for missing styles or unregistered polyfills. Never apply blanket false to a legacy dependency without integration testing, because implicit side effects (window mutation, prototype extension) fail silently. The most common false negative — popular utility libraries that ship as CommonJS — is walked through end to end in fixing tree shaking issues with lodash and moment.

4. Apply the Fix: Minifier-Level Dead Code Elimination

While tree shaking operates at the module boundary, DCE runs at the Abstract Syntax Tree level during minification. Minifiers analyze control-flow graphs to eliminate dead branches, unused variables, and calls explicitly marked side-effect-free. The /*#__PURE__*/ annotation is the critical signal: without it, minifiers conservatively retain calls that might mutate external state.

javascript
// Mark a factory call as removable when its result is unused.
export const icon = /*#__PURE__*/ createIcon('arrow');
// trade-off: only annotate genuinely pure calls. If createIcon registers the
// icon in a global registry as a side effect, the PURE hint will silently
// strip that registration in production and break runtime lookups.

Configuration thresholds that reliably trigger DCE:

  • Terser: compress: { dead_code: true, unused: true, drop_console: true } and pure_funcs: ['console.debug', 'console.info'].
  • esbuild: pure: ['console.log', 'debug'] and drop: ['console', 'debugger'] to strip debug calls at compile time.

Compare pre- and post-minified sizes. A >15% reduction post-minification indicates healthy DCE; below 10% points to missing /*#__PURE__*/ annotations or overly conservative settings.

Deconstructing the Removal Pipeline into Phases

Treat dead-code removal as a sequence of phases, each with its own diagnostic and its own failure mode. Isolating the dominant phase before touching configuration prevents chasing the wrong fix.

  • Phase 1 — Graph resolution (build time): the bundler decides which exports are reachable. Failure mode: dynamic access or CommonJS interop forces full inclusion. Diagnostic: usedExports: null in stats.json.
  • Phase 2 — sideEffects pruning (build time): files marked pure are dropped from the graph. Failure mode: missing or over-broad sideEffects. Diagnostic: chunk contains a module no entry references.
  • Phase 3 — Minifier DCE (build time): dead branches and unused locals are removed at the AST level. Failure mode: missing /*#__PURE__*/, conservative compress. Diagnostic: post-minify reduction below 10%.
  • Phase 4 — Runtime validation (field): unused code that loaded but never ran. Failure mode: route-level imports leaking shared vendor. Diagnostic: DevTools Coverage above 20% unused on first interaction.

Each phase has a distinct budget; attack whichever one carries the largest unused delta first.

Advanced Diagnostics: Framework and Edge-Case Failure Modes

Tree shaking interacts directly with code-splitting boundaries. When using import(), bundlers emit separate chunks, but static analysis still applies within each chunk. Unused dependencies leak across route boundaries when shared utilities are imported at the root level instead of isolated to route components. Keep heavy dependencies local to the route that needs them; the boundary-alignment patterns in dynamic imports and route-based splitting prevent vendor bloat from bleeding into every chunk.

DCE principles extend to stylesheets. CSS-in-JS and CSS Modules support static analysis, while global CSS needs explicit purging. Misconfigured purging strips dynamically generated class names and causes layout shift in production:

javascript
// postcss.config.js
module.exports = {
  plugins: [
    require('@fullhuman/postcss-purgecss')({
      content: ['./src/**/*.html', './src/**/*.js', './src/**/*.vue'],
      safelist: [/^animate-/, /^tooltip-/, 'html', 'body'],
      rejected: true // outputs an audit log of removed selectors
    })
  ]
};
// trade-off: PurgeCSS pays off on utility-class-heavy stylesheets but is
// dangerous with runtime-generated class names — without an accurate safelist
// it removes styles your app constructs at runtime, so skip it where class
// names are computed rather than written literally.

A >40% CSS reduction indicates successful purging for utility-class-heavy stylesheets; lower than that on a Tailwind-style codebase usually means the content globs miss a template directory.

Validation and Budgeting: Lock the Result in CI

Build-time DCE reduces parse and compile time and network transfer, but runtime efficiency still depends on execution patterns. Pair pruning with a CI budget so regressions surface in the pull request rather than in field data:

javascript
// lighthouserc.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        'unused-javascript': ['error', { maxNumericValue: 10000 }], // bytes
        'total-byte-weight': ['error', { maxNumericValue: 300000 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }]
      }
    }
  }
};
// trade-off: hard byte budgets catch regressions but block legitimate feature
// growth — review the ceilings each quarter so the gate stays a guardrail
// rather than a tax that teams route around with --no-verify.

Run lighthouse-ci on every pull request and fail when unused-javascript exceeds 10KB or total-byte-weight crosses budget. Validate downstream with Real User Monitoring: confirm the reduced bundle actually moves field LCP and Time to Interactive, not just lab numbers. For deeper treemap-level attribution and CI wiring, see webpack bundle analysis techniques.

Production Configuration Reference

Webpack: Used Exports and Side Effects

javascript
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true,
    sideEffects: true,
    concatenateModules: true,
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            dead_code: true,
            unused: true,
            drop_console: true,
            pure_funcs: ['console.debug', 'console.info']
          }
        }
      })
    ]
  }
};
// trade-off: concatenateModules (scope hoisting) shrinks output and speeds
// execution, but it lengthens build time and can obscure module boundaries in
// stack traces — disable it during deep debugging sessions, not in production.

Vite: esbuild Pure Functions and Tree Shaking

javascript
// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: 'esnext',
    minify: 'esbuild',
    rollupOptions: {
      output: {
        manualChunks: (id) => {
          if (id.includes('node_modules')) return 'vendor';
        }
      }
    }
  },
  esbuild: {
    pure: ['console.log', 'console.warn'],
    drop: ['debugger']
  }
});
// trade-off: a single 'vendor' chunk maximizes long-term cache hits but
// couples unrelated dependencies — one dependency bump invalidates the whole
// chunk, so split vendor by update cadence once it grows past ~150KB.

Rollup: Aggressive Treeshake Tuning

javascript
// rollup.config.js
export default {
  input: 'src/main.js',
  output: { file: 'dist/bundle.js', format: 'es' },
  treeshake: {
    moduleSideEffects: 'no-external',
    propertyReadSideEffects: false,
    tryCatchDeoptimization: false
  }
};
// trade-off: propertyReadSideEffects:false lets Rollup drop more code but
// assumes getters are pure — if a dependency uses a getter with observable
// side effects, this will eliminate code it should have kept.

Common Implementation Mistakes

  • Importing entire namespaces (import * as _ from 'lodash') instead of named exports, which breaks static analysis.
  • Setting sideEffects: false globally without auditing CSS, polyfills, or global-registry mutations, causing silent runtime failures.
  • Relying on minifier DCE alone without enabling usedExports or sideEffects, leaving unused modules in the graph.
  • Ignoring /*#__PURE__*/ annotations in custom utility libraries, blocking safe removal of unused calls.
  • Mixing CommonJS and ESM in the same dependency chain, forcing the bundler to disable tree shaking for safety.
  • Importing shared vendor modules inside route chunks, leaking dead weight across every route boundary.
  • Failing to purge dynamically generated class names in CSS, producing false-positive removal of critical styles.