Fixing Tree-Shaking Failures with Webpack 5

Symptom you are seeing: webpack-bundle-analyzer shows a full vendor library included in your production chunk even though you only import one function from it. In verbose mode, the stats output contains lines like "usedExports": false or "type": "cjs require" against modules you expected to be eliminated. Vendor chunk sizes stay flat across deploys despite removing imports — a classic sign of a silent tree-shaking failure.


Root cause: CJS interop boundaries block static analysis

Webpack 5 eliminates dead exports through a three-pass static analyser: providedExports maps what each module advertises, usedExports marks what consumers actually import, and concatenateModules (scope hoisting) merges the remainder into a single closure. The chain breaks the moment any node in the dependency graph resolves to a CommonJS entry point.

With module.exports = { ... }, the entire object is a single runtime value. Webpack cannot know at build time which properties will be accessed, so it conservatively marks every export as used and retains the full module. The same failure mode appears when a package ships an ESM file that internally calls require() — the dynamic boundary forces Webpack to emit a runtime interop wrapper (__esModule: true, Object.defineProperty) that defeats static pruning.

This is the core challenge covered by converting CJS libraries to ESM for better bundling. When you cannot rewrite the library itself, the fix lives in your Webpack configuration. The broader context — why dead code survives at all and how sideEffects declarations interact with the analyser — is part of the advanced tree-shaking and dependency optimization discipline.


Step 1 — Locate the failure with a stats dump

# Webpack 5 — generate a machine-readable stats file
npx webpack --mode=production --stats=verbose --json=stats-raw.json

Parse the output to find modules that blocked the analyser:

# Find modules where Webpack gave up on static analysis
jq '.modules[] | select(
  .usedExports == false or
  (.reasons[]? | .type == "cjs require")
) | { id: .id, name: .name, usedExports: .usedExports }' stats-raw.json > flagged-modules.json

Any module whose usedExports is false (not an empty array, but the boolean false) was evaluated as opaque — Webpack saw a CJS boundary and stopped trying. Cross-reference these module names against your node_modules to identify which packages are the culprits.

Additionally, run webpack in development mode to surface harmony export warnings:

# Webpack 5 — development mode exposes unused harmony export annotations
npx webpack --mode=development --stats-children 2>&1 | grep "unused harmony export"

unused harmony export foo means Webpack found the dead export but sideEffects is blocking removal. harmony export appearing without “unused” means the export is being retained because something downstream imports it — check your own code for indirect barrel imports.


Diagnosing the CJS interop boundary

The primary failure vectors, in order of frequency:

1. Library ships CJS as its main entry. The package.json main field points to dist/index.js compiled to module.exports = .... Webpack resolves main by default before looking for module or exports fields.

2. Barrel file re-exports everything. An index.js that does export * from './utils'; export * from './helpers'; forces Webpack to include all sub-modules unless each is individually marked side-effect-free. This interacts badly with the refactoring barrel files to reduce bundle bloat problem — even a well-structured ESM barrel can defeat pruning if the library’s own internal barrels are not annotated.

3. Over-inclusive sideEffects globs. A package.json entry like "sideEffects": ["**/*.js"] marks every file as impure, forcing Webpack to keep all of them. This is subtly different from "sideEffects": true (the default when the field is absent) but equally damaging.

Check for runtime interop markers in compiled output:

# Search for CJS interop footprint in dist
grep -r '__esModule' dist/
grep -r 'Object.defineProperty(exports' dist/

If these appear in vendor chunks for a library you expected to be tree-shaken, the library’s ESM build was not picked up — the CJS build was used instead.


The fix: webpack.config.js overrides

The following configuration resolves the three failure vectors above. Apply it incrementally — add conditionNames first, rebuild, measure, then add the innerGraph and sideEffects overrides.

// Webpack 5 — full tree-shaking configuration for CJS interop resolution
const webpack = require('webpack');

module.exports = {
  mode: 'production',

  optimization: {
    // providedExports: build a map of what each module exports
    providedExports: true,
    // usedExports: mark individual exports as used/unused
    usedExports: true,
    // innerGraph: trace variable references across module scope boundaries
    innerGraph: true,
    // sideEffects: honour the sideEffects field in package.json
    sideEffects: true,
    // concatenateModules: scope-hoist eligible modules into one closure
    concatenateModules: true,
  },

  resolve: {
    // Prefer the ESM condition in package.json exports maps
    conditionNames: ['module', 'import', 'require'],
    // Prefer 'module' over 'main' for legacy packages without exports maps
    mainFields: ['module', 'browser', 'main'],
    // Allow legacy node_modules that omit explicit file extensions
    fullySpecified: false,
  },

  module: {
    rules: [
      {
        // Treat .mjs and .js in node_modules as auto-detected
        // so Webpack applies both CJS and ESM parsing strategies
        test: /\.m?js$/,
        type: 'javascript/auto',
        resolve: { fullySpecified: false },
      },
      {
        // Override sideEffects for a specific package you know is pure
        // Use this when the library's package.json is wrong or missing the field
        test: /node_modules[\\/]lodash-es[\\/]/,
        sideEffects: false,
      },
    ],
  },
};

Why conditionNames matters: When a package ships an exports map like { "import": "./esm/index.mjs", "require": "./cjs/index.js" }, Webpack picks the correct entry based on conditionNames. Without 'module' and 'import' in that list, Webpack defaults to 'require' and loads the CJS build — silently, with no warning.

Fallback: alias to a known-good ESM file

When conditionNames is not enough (the library lacks an exports map entirely), redirect the import via a resolve.alias:

// Webpack 5 — alias a CJS package to its pre-built ESM shim
module.exports = {
  resolve: {
    alias: {
      // Point the CJS entry to the ESM build explicitly
      'legacy-cjs-lib': 'legacy-cjs-lib/esm/index.mjs',
    },
  },
};

Fallback: IgnorePlugin for known dead branches

If a library conditionally requires locale data, polyfills, or optional integrations that you never use, exclude them at the plugin level:

// Webpack 5 — statically exclude locale sub-modules from moment.js
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.IgnorePlugin({
      resourceRegExp: /^\.\/locale$/,
      contextRegExp: /moment$/,
    }),
  ],
};

IgnorePlugin is a hard exclusion — it works even when the module is dynamically required, making it the right tool when innerGraph cannot prove a branch is dead.


Webpack 5 vs Vite 5+: side-by-side for the same scenario

Webpack 5 vs Vite 5+ tree-shaking configuration for CJS interop Two-column diagram comparing how Webpack 5 uses conditionNames and innerGraph against how Vite 5+ uses Rollup's moduleSideEffects and ssr.noExternal to achieve the same CJS-to-ESM tree-shaking result. Webpack 5 Vite 5+ (Rollup) optimization.usedExports: true optimization.innerGraph: true resolve.conditionNames: ['module','import','require'] resolve.mainFields: ['module','browser','main'] module.rules: sideEffects: false IgnorePlugin for dead branches both resolve CJS → ESM at build time build.rollupOptions.treeshake: moduleSideEffects: false resolve.conditions: ['module','browser','jsnext'] optimizeDeps.include: [pkg] ssr.noExternal: [pkg] plugin: vite-plugin-commonjs (for CJS packages without exports)

Vite 5+ uses Rollup’s tree-shaker under the hood, which is more aggressive than Webpack’s by default — Rollup assumes modules are side-effect-free unless told otherwise. The practical difference: packages that silently retain dead code in Webpack 5 often work correctly in Vite 5+ without additional configuration. If you’re evaluating bundler choice, the understanding ES modules vs CommonJS in bundlers guide covers the runtime semantics that explain why.

For Vite 5+, the equivalent configuration is:

// Vite 5+ — tree-shaking for CJS libraries without exports maps
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    // Prefer ESM conditions in package.json exports maps
    conditions: ['module', 'browser', 'jsnext:main'],
  },
  optimizeDeps: {
    // Pre-bundle CJS packages so Vite's ESM dev server can import them
    include: ['legacy-cjs-lib'],
  },
  build: {
    rollupOptions: {
      treeshake: {
        // Treat all modules as side-effect-free unless annotated otherwise
        moduleSideEffects: false,
        // Enable annotation comments (/*#__PURE__*/ etc.)
        annotations: true,
      },
    },
  },
});

Step-by-step verification checklist

Complete these steps in order. Each one provides a distinct signal — do not skip a step because a later one looks cleaner.

  1. Rebuild production and generate the post-fix stats file:

    npx webpack --mode=production --json=stats-fixed.json
  2. Diff pre- and post-fix stats with Statoscope:

    npx @statoscope/cli diff --input stats-fixed.json --reference stats-raw.json

    Look for: reduced module count in the vendor chunk, usedExports flipping from false to an array, and elimination of __esModule interop wrappers. A successful fix typically reduces the vendor chunk by 30–60% for libraries like lodash, date-fns, or @mui/material when imported selectively.

  3. Confirm innerGraph pruned conditional branches:

    npx webpack --mode=development --stats-children 2>&1 | grep "unused harmony export"

    Every unused harmony export line that disappears from this output represents a code path that will now be pruned in production.

  4. Inspect the bundle with source-map-explorer:

    npx source-map-explorer dist/vendor.*.js --html report.html

    Open report.html and confirm the library shows only the modules you actually import. If the entire library namespace appears as a single block, the CJS build was still used — re-check conditionNames and mainFields.

  5. Measure real-world gzip size:

    gzip -c dist/vendor.*.js | wc -c

    Compare against the baseline. A correct fix for a selectively-imported utility library (lodash-es, date-fns, ramda) should show at least 15–40% gzip reduction. If the number is unchanged after the configuration fix, the library does not have a true ESM build and you need the resolve.alias fallback.

  6. Run a CI bundle size budget check (add to your pipeline):

    # Webpack 5 — fail CI if vendor chunk exceeds 150 KB gzipped
    node -e "
    const fs = require('fs');
    const stats = JSON.parse(fs.readFileSync('stats-fixed.json', 'utf8'));
    const vendor = stats.assets.find(a => a.name.startsWith('vendor'));
    if (vendor && vendor.size > 150_000) {
      console.error('Vendor chunk too large:', vendor.size);
      process.exit(1);
    }
    "

Edge cases and gotchas

Gotcha 1: sideEffects: false on the wrong package

Setting sideEffects: false via module.rules on a package that registers global polyfills or patches Array.prototype will silently break those features in production. Always verify what a package’s side effects actually are before overriding. CSS imports inside JS files (import './styles.css') are also side effects — a blanket sideEffects: false on a component library will strip its styles.

Safe approach: use specific file globs in the sideEffects array rather than the boolean shorthand:

// package.json of the library (or your module.rules override equivalent)
{
  "sideEffects": ["**/*.css", "src/polyfills.js"]
}

Gotcha 2: concatenateModules breaks with circular dependencies

Scope hoisting (concatenateModules: true) requires a strict DAG — circular module references prevent hoisting for the entire cycle. You can detect this:

npx webpack --mode=production --display-optimization-bailout 2>&1 | grep "ModuleConcatenation bailout"

If you see Cannot concat with [module] because of a circular reference, the two modules will each be emitted as separate runtime chunks regardless of how clean their exports are. Resolve the circular dependency first — the configuring sideEffects for optimal tree-shaking guide covers the interaction between circular deps and the sideEffects flag in detail.

Gotcha 3: Different results in dev vs production

Webpack 5 only applies usedExports pruning and scope hoisting in mode: 'production'. In development mode, all exports are preserved to support HMR and source-map accuracy. If you are debugging tree-shaking failures, always generate stats in mode: 'production' — development stats will show false-positive retained exports that do not appear in the production bundle. Conversely, an export that appears unused in development stats might still be pulled in via a dynamic import path that only activates at runtime.


FAQ

Why does Webpack 5 keep dead code even with usedExports: true?

The static analyser stops at module.exports boundaries. If any import in the dependency chain resolves to a CommonJS entry point, Webpack cannot safely prune individual exports and retains the entire module.

How do I force Webpack 5 to use a library’s ESM build?

Set resolve.conditionNames to ['module', 'import', 'require'] so Webpack prefers the module condition in the package.json exports map before falling back to the CJS main field.

What does “unused harmony export” in the webpack stats mean?

It confirms Webpack identified the export as dead but has not removed it — usually because sideEffects is missing or set to true on the containing module. Add "sideEffects": false to the library’s package.json or apply a module.rules override targeting that package.