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.
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: trueinpackage.jsonbypass dead code elimination entirely. - Incorrect Polyfills: Legacy shims bundled due to
@babel/preset-envtargeting or implicitrequire()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.
- CJS Interop Fallbacks Breaking Static Analysis: Rollup wraps CommonJS modules in synthetic ESM wrappers. If a CJS package mutates
module.exportsdynamically (e.g.,exports[name] = fn), Rollup cannot statically determine which exports are used. It defaults to bundling the entire object. - Missing or Incorrectly Scoped
sideEffects: false: Package authors must explicitly declare purity. Ifpackage.jsonomitssideEffects: falseor uses overly broad glob patterns ("src/**/*.css"), Rollup assumes every file has global side-effects and skips pruning. - 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 dependencyorModule included despite unused importswarnings 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: truereorders your mental model. A node that looks large uncompressed may gzip to almost nothing (repetitive vendor code). Sort by gzip when prioritizing fixes.open: trueis useless in CI. Headless runners have no browser. Emit thejsontemplate and assert on it programmatically instead.- Dual visualizer plugins write the same default filename. When emitting both
treemapandjson, set distinctfilenamevalues or the second overwrites the first.
Related
- Tree-Shaking Mechanics and Dead Code Elimination — the static-analysis rules that decide what the treemap retains.
- Eliminating Barrel File Side Effects in Tree-Shaking — the most common ghost-module cause this workflow surfaces.
- Understanding ESM vs CommonJS in Modern Bundlers — why CJS interop wrappers show up as unprunable nodes.