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. Note that visualizers expose the output graph; they do not bypass the static analysis limitations inherent to JavaScript’s dynamic evaluation model.

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
 })
 ],
 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 && open dist/stats.html",
 "rollup:analyze": "rollup -c && open stats.html"
 }
}

Run npm run build:analyze. The --mode production flag is mandatory to trigger Rollup’s treeshake and minification passes.

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:

// vite.config.ts or rollup.config.js
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.