Debugging Tree-Shaking Failures with rollup-plugin-visualizer

Production bundles frequently retain unused exports, legacy polyfills, or vendor code despite explicit tree-shaking flags. This occurs when static analysis cannot guarantee module purity or when interop layers introduce runtime side-effects. The diagnostic workflow relies on rollup-plugin-visualizer to render the dependency graph, enabling precise isolation of retained AST nodes across Vite, Rollup, and esbuild pipelines; for the static-analysis rules behind those retentions, start from Tree-Shaking Mechanics and Dead Code Elimination. Note that visualizers expose the output graph; they do not bypass the static analysis limitations inherent to JavaScript’s dynamic evaluation model.

Treemap-driven debugging loop for retained dead code A production build feeds a visualizer treemap whose heavy nodes are classified, traced to a root cause, patched, and re-verified against the baseline. Production build vite build --mode Treemap report stats.html Heavy ghost node full lib retained Classify cause sideEffects / CJS Patch sideEffects or refactor import() Re-verify vs baseline stats.json diff root cause CI gate: assert bundle budget and forbidden-module set on every PR node scripts/check-bundle.js — fail the build on regression
Figure: the visualizer turns retained bytes into a classifiable node, closing the diagnose-patch-verify loop.

Prerequisites & Reproducible Configuration

Establish a deterministic baseline. Tree-shaking only activates during production builds where minification and dead code elimination passes are enabled. Dev-server wrappers and HMR proxies inject runtime code that masks true bundle composition.

Install the plugin: npm i -D rollup-plugin-visualizer

vite.config.ts

import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    visualizer({
      open: true,
      gzipSize: true,
      template: 'treemap',
      filename: 'dist/stats.html',
      emitFile: true
    }),
    visualizer({
      template: 'json',
      filename: 'dist/stats.json', // machine-readable output consumed by CI scripts
      emitFile: true
    })
  ],
  build: {
    minify: 'terser',
    rollupOptions: {
      output: { manualChunks: undefined } // Disable auto-splitting for baseline analysis
    }
  }
});

rollup.config.js

import { visualizer } from 'rollup-plugin-visualizer';
import resolve from '@rollup/plugin-node-resolve';

export default {
  input: 'src/index.js',
  output: { dir: 'dist', format: 'esm', sourcemap: true },
  plugins: [
    resolve(),
    visualizer({ template: 'treemap', gzipSize: true, filename: 'stats.html' })
  ],
  treeshake: {
    moduleSideEffects: 'no-external', // Aggressively prune external packages
    propertyReadSideEffects: false,
    tryCatchDeoptimization: false
  }
};

package.json scripts

{
  "scripts": {
    "build:analyze": "vite build --mode production",
    "rollup:analyze": "rollup -c"
  }
}

Run npm run build:analyze. The --mode production flag is mandatory to trigger Rollup’s treeshake and minification passes. After the build completes, open dist/stats.html manually or set open: true in the visualizer options to auto-open it.

Identifying Failure Signatures in the Visualizer Output

The treemap renders chunk weight proportional to byte size. Focus on large, monolithic nodes representing vendor libraries or unexpected polyfills. Cross-reference these against your import graph.

Common visual artifacts map directly to bundler behavior:

  • Ghost Modules: Entire libraries appear despite importing only a single named export. This indicates the package lacks proper ESM entry points or uses barrel files that trigger full evaluation.
  • Unpruned Side-Effects: Modules flagged with sideEffects: true in package.json bypass dead code elimination entirely.
  • Incorrect Polyfills: Legacy shims bundled due to @babel/preset-env targeting or implicit require() calls.

Interpret the color-coding: red/orange nodes typically indicate heavy vendor chunks, while blue/green represent application code. When tracing retained code, remember that Tree-Shaking Mechanics and Dead Code Elimination relies on pure function assumptions. Any mutation or global state access forces the bundler to preserve the entire module.

Exact Error Signatures to Watch:

  • "Module 'lodash' is included despite unused exports"
  • "Unexpected 'require()' calls in ESM output"
  • "Side-effect warnings in console during build"

Root-Cause Analysis: Common Failure Patterns

Tree-shaking failures rarely stem from bundler bugs. They originate from architectural patterns that defeat static AST traversal.

  1. CJS Interop Fallbacks Breaking Static Analysis: Rollup wraps CommonJS modules in synthetic ESM wrappers. If a CJS package mutates module.exports dynamically (e.g., exports[name] = fn), Rollup cannot statically determine which exports are used. It defaults to bundling the entire object.
  2. Missing or Incorrectly Scoped sideEffects: false: Package authors must explicitly declare purity. If package.json omits sideEffects: false or uses overly broad glob patterns ("src/**/*.css"), Rollup assumes every file has global side-effects and skips pruning.
  3. Dynamic import() with Variable Paths: import(`./modules/${name}.js`) prevents static resolution. The bundler cannot construct the module graph at compile time, forcing it to bundle all matching files or fail chunk isolation.

These patterns stem from fundamental differences in module resolution strategies. For deeper context on how static versus dynamic resolution impacts the dependency graph, review Core Concepts of Modern Bundling. Barrel files (index.ts re-exporting everything) exacerbate this by creating implicit dependency chains that mask unused code paths from the analyzer.

Step-by-Step Fixes & Verification Workflow

Apply targeted remediation based on the identified failure pattern.

1. Patch package.json sideEffects Field If a dependency lacks purity declarations, override it in your root package.json:

{
  "sideEffects": [
    "*.css",
    "*.scss",
    "node_modules/legacy-lib/dist/polyfill.js"
  ]
}

This tells Rollup to prune all other modules in that package.

2. Refactor Dynamic Imports to Static Literals Replace runtime string interpolation with explicit static paths or a lookup map:

// ❌ Fails tree-shaking
const mod = await import(`./features/${featureName}.js`);

// ✅ Preserves static analysis
const featureMap = {
  auth: () => import('./features/auth.js'),
  dashboard: () => import('./features/dashboard.js')
};
const mod = await featureMap[featureName]();

3. Configure Rollup treeshake Options Explicitly Force aggressive pruning for known-safe external packages:

// rollup.config.js — for Vite, nest under build.rollupOptions.treeshake
export default {
  treeshake: {
    moduleSideEffects: (id, external) => {
      if (external && id.includes('known-safe-lib')) return false;
      return true; // Default to safe
    }
  }
};

4. Validate with npm run build and Visualizer Comparison

  • Run npm run build:analyze.
  • Inspect dist/ size delta. Target >15% reduction for vendor-heavy apps.
  • Validate chunk graph isolation: ensure no unexpected cross-chunk dependencies.
  • Confirm zero Circular dependency or Module included despite unused imports warnings in build logs.

Compare the new stats.html against the baseline. The previously monolithic vendor node should fragment into isolated, pruned chunks.

CI Integration & Automated Regression Testing

Manual inspection does not scale. Integrate automated bundle assertions into your CI/CD pipeline to catch tree-shaking regressions before merge.

Node.js Threshold Assertion Script (scripts/check-bundle.js)

import { readFileSync } from 'fs';
import { resolve } from 'path';

const statsPath = resolve('dist/stats.json');
const stats = JSON.parse(readFileSync(statsPath, 'utf-8'));

const MAX_BUNDLE_SIZE_KB = 150;
const FORBIDDEN_MODULES = ['lodash', 'moment/locale'];

let totalSize = 0;
const foundModules = new Set();

// Parse visualizer JSON structure
function traverse(node) {
  if (node.name) foundModules.add(node.name);
  if (node.size) totalSize += node.size;
  node.children?.forEach(traverse);
}
traverse(stats);

const sizeKB = totalSize / 1024;
const violations = FORBIDDEN_MODULES.filter(m => foundModules.has(m));

if (sizeKB > MAX_BUNDLE_SIZE_KB) {
  console.error(`Bundle size ${sizeKB.toFixed(2)}KB exceeds limit ${MAX_BUNDLE_SIZE_KB}KB`);
  process.exit(1);
}

if (violations.length > 0) {
  console.error(`Tree-shaking regression detected: ${violations.join(', ')} included`);
  process.exit(1);
}

console.log(`Bundle validated: ${sizeKB.toFixed(2)}KB, zero forbidden modules`);

CI Pipeline Step (GitHub Actions)

- name: Analyze Bundle & Assert Tree-Shaking
  run: |
    npm run build:analyze
    node scripts/check-bundle.js

Configure the pipeline to fail if the bundle size increases by >5% relative to the previous successful build, or if FORBIDDEN_MODULES reappear. This enforces strict adherence to static analysis guarantees and prevents silent vendor bloat from reaching production.

Gotchas & Edge Cases

  • Treemap shows the output, not intent. A node sized at 40 KB tells you what survived, not why. Always cross-reference the import chain in the visualizer sidebar before patching.
  • gzipSize: true reorders your mental model. A node that looks large uncompressed may gzip to almost nothing (repetitive vendor code). Sort by gzip when prioritizing fixes.
  • open: true is useless in CI. Headless runners have no browser. Emit the json template and assert on it programmatically instead.
  • Dual visualizer plugins write the same default filename. When emitting both treemap and json, set distinct filename values or the second overwrites the first.