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: 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:
// 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 dependencyorModule included despite unused importswarnings in build logs. Compare the newstats.htmlagainst 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.