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.
The problem exists because tree-shaking is a conservative optimization. A bundler may only remove code it can prove is unreachable and free of observable side-effects, and JavaScript makes that proof hard: a top-level new RegExp(), a getter that mutates state, a prototype patch, or a bare function call can all change program behavior in ways the analyzer cannot see through. When Rollup is unsure, it does the safe thing and keeps the code. The result is a bundle that ships kilobytes of logic no route ever executes — dead weight that inflates parse time, delays time-to-interactive, and quietly grows every time a new dependency is added. Nothing in the build log announces this; the build succeeds, the app works, and the regression is invisible until someone measures it.
The visualizer’s job is to make that invisible retention legible. It sits at the tail of the pipeline, after resolution, transformation, tree-shaking, and chunking have all run, and it reports the byte cost of every module that survived into a chunk. That position is exactly why it is a debugging tool and not a fix: it cannot tell you why a module was kept, only that it was. The engineering work is reading the treemap as evidence, forming a hypothesis about which architectural pattern defeated static analysis, patching that pattern at its source, and then re-running the build to confirm the node shrank or disappeared. This page walks that diagnose-patch-verify loop end to end and then wires it into CI so a fixed regression cannot silently return.
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.
The distinction matters more than it first appears. In dev, Vite serves unbundled ES modules straight to the browser and never runs Rollup’s tree-shaking pass at all — every module you import is delivered whole, on demand, so a dev “bundle” tells you nothing about what production will prune. Measuring dead code against the dev graph is measuring the wrong artifact. Reproducibility is the second requirement: run the build twice from a clean dist/ and confirm the treemap is byte-stable before you trust any delta you read from it. If the two runs disagree, a non-deterministic input is in play — usually a plugin that stamps a timestamp or hash into the output, or a dependency resolved from a floating version range. Pin those first, because a debugging loop built on a shifting baseline will chase noise.
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.
Two configuration choices in the snippets above are load-bearing. Emitting both a treemap template and a json template from two separate plugin instances gives you one artifact for eyes and one for scripts; the JSON is what the CI gate later parses, and generating it in the same build guarantees the two views describe the identical bundle. Setting manualChunks: undefined for the baseline is deliberate too — automatic chunk-splitting scatters a single logical dependency across several files, which makes a heavy library harder to spot as one node. Analyze with splitting off first to locate the retention, then turn your production splitting strategy back on once the source problem is fixed. Keep minify: 'terser' (or esbuild’s minifier) enabled during analysis: minification runs the final dead-code pass, and a treemap of un-minified output overstates every node and hides which bytes actually survive to production.
How the visualizer builds the treemap under the hood
rollup-plugin-visualizer hooks Rollup’s generateBundle phase, which fires once per output after tree-shaking and chunking are complete. At that point Rollup hands the plugin the rendered chunks plus each chunk’s modules map, where every entry records renderedLength — the byte count that module actually contributed to the emitted chunk, not its size on disk. The plugin sums those rendered lengths up the module-path hierarchy to produce the nested rectangles you see; a directory’s area is the sum of its children. This is why the treemap measures post-elimination weight: a module that was tree-shaken to nothing has a renderedLength of zero and never appears. When gzipSize: true is set, the plugin additionally compresses each chunk’s rendered source and attaches the compressed figure, which is computed independently of the raw byte count — the reason a node can look large by one metric and trivial by the other. Because the data comes from Rollup’s own bundle object rather than a second parse of the files, the numbers match what ships exactly, but they inherit Rollup’s module-ID naming, which is why a virtual module or a CJS-wrapped package can show up under a synthetic path you did not write.
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. The symptom is a vendor node whose size is wildly out of proportion to what you use — you imported
debounceand the whole oflodashis in the chunk. The root cause is almost always that the package resolves to a CommonJS entry, or ships an ESM barrel that re-exports every submodule from oneindex.js, so Rollup cannot see that the other exports are unreferenced. Confirm it by opening the node in the sidebar: if the retained files are dozens of submodules you never named, it is a ghost. The fix is to import from the package’s deep ESM paths (lodash-es/debounce) or replace the barrel dependency outright. - Unpruned Side-Effects: Modules flagged with
sideEffects: trueinpackage.jsonbypass dead code elimination entirely. The symptom is that a module survives even though nothing imports its bindings — only its path is referenced. Rollup treats a side-effectful module as a load-bearing statement that must run for its effects, so it keeps the whole file. This is correct behavior for CSS imports and polyfills, and wrong behavior for a library that over-declared. Confirm by checking the dependency’spackage.jsonfor a missing or overly broadsideEffectsfield, then override it as shown below. - Incorrect Polyfills: Legacy shims bundled due to
@babel/preset-envtargeting or implicitrequire()calls. The symptom iscore-jsinternals, regenerator runtime, ormomentlocale files appearing in a modern-browser build. The root cause is usually abrowserslisttarget broader than your real audience, which instructs preset-env to inject transforms and their supporting shims. Confirm by checking the effective browserslist and whetheruseBuiltIns: 'usage'is set; narrowing the target list removes the shims at the source rather than trying to prune them after injection.
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, via
@rollup/plugin-commonjs, wraps CommonJS modules in synthetic ESM wrappers so the rest of the graph can import them with named-binding syntax. Named exports from CJS only work when the plugin can statically detect them by scanning the module’s top-level assignments toexports.fooandmodule.exports.foo. The moment a package assigns exports dynamically —exports[name] = fninside a loop, or a computedObject.defineProperty— that static scan fails. The plugin falls back to exposing the module as a single default export object, and because any property of that object might be the one you use, the whole object and everything it transitively requires is retained. The consequence is that one non-analyzable CJS dependency can anchor a large subtree of otherwise-dead code. The durable fix is to prefer the package’s ESM build if one exists, or isolate the CJS dependency behind a thin wrapper module that re-exports only the specific functions you need. - 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. The field is a contract:falsepromises that importing any module for its bindings alone can be elided if those bindings are unused. Omitting it is not neutral — the absence is read as “assume the worst”, so a dependency that simply forgot to add the field pays the full retention penalty. The opposite failure is a field so broad it re-flags pure modules as effectful; a pattern like"src/**/*.js"intended to protect one CSS-injecting entry accidentally pins the entire source tree. The consequence in both directions is silent over-retention, and the field’s own author, not the bundler, is where the fix lives. - Dynamic
import()with Variable Paths:import(`./modules/${name}.js`)prevents static resolution. Rollup needs a string literal (or a template with a fixed prefix and a[name]glob it can enumerate at build time) to know which files enter the graph. An interpolated path with an arbitrary variable forces one of two bad outcomes: either the bundler globs every file that could match the pattern into the graph and splits none of them cleanly, or it emits the import unresolved and defers it to a runtime that may 404 in production. Either way the chunk-isolation you wanted from code-splitting evaporates. The fix is a static lookup map, shown below, that turns a runtime string into a set of literalimport()calls the analyzer can trace one by one.
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. The array form is an allowlist of the only files that carry side-effects; everything not matched becomes eligible for elimination. Keep the list as narrow as the truth allows — every entry you add is a file the bundler will refuse to drop. Confirm the override took effect by rebuilding and checking that the previously retained submodules have left the treemap; if they persist, the path patterns are not matching the resolved module IDs, which are absolute paths under node_modules, so anchor them accordingly.
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]();
Each value in featureMap is a literal import() call, so Rollup emits one lazily-loaded chunk per feature and the map itself only holds references to those chunks. The runtime string featureName now selects among already-resolved entry points instead of constructing a path the bundler never saw. The same technique applies to eager named imports: a barrel re-export such as import { auth, dashboard } from './features' can be rewritten to direct-path imports so an unused feature drops entirely rather than being dragged in by the barrel.
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
}
}
};
The moduleSideEffects callback is the escape hatch for the case where you know a package is pure but its author never declared it. Returning false for a specific external tells Rollup to treat that module as elidable when unused, overriding the conservative default. Scope the predicate tightly and default to true, as shown — a blanket false will strip a genuinely effectful dependency and produce a bundle that loads but misbehaves at runtime, which is a far worse failure than a few retained kilobytes because it passes the build and breaks in production. propertyReadSideEffects: false and tryCatchDeoptimization: false from the earlier config are related levers: the first promises that reading a property never triggers a getter with effects, and the second stops Rollup from bailing out of optimization inside try blocks. Both are safe for typical application code and unsafe if a dependency relies on such tricks, so change them globally only after confirming your graph does not.
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. Verification is not optional and it is not “the app still works” — a broken tree-shake usually still runs. The proof is in the numbers: the specific node you targeted must shrink or vanish, and the total must drop by roughly what that node weighed. If the total barely moved, the code was reachable through a path you did not sever and the patch missed; re-open the node’s import chain and find the second reference. If the total dropped but a route now throws at runtime, you pruned something effectful and must narrow the override. Treat the stats.json diff, not intuition, as the acceptance test.
CI Integration & Automated Regression Testing
Manual inspection does not scale. A treemap someone remembers to open is a control that fails the week everyone is busy, and tree-shaking regressions are exactly the kind of change that slips through review: adding one broadly-imported dependency, or flipping a barrel import, can re-inflate a chunk without touching a single line that looks suspicious in a diff. The durable answer is to make the byte budget an assertion the pipeline checks on every pull request, so the regression fails a required status check instead of reaching production. 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.
Two refinements make the gate trustworthy in practice. First, assert on gzip or brotli size rather than raw bytes — that is what users download, and a raw-byte budget will either nag on incompressible changes or wave through compressible bloat. The visualizer JSON carries the gzip figure when gzipSize: true was set, so read that field instead of renderedLength. Second, prefer a FORBIDDEN_MODULES allowlist over a pure size threshold for the failure modes you have already fixed: a named ban on moment/locale or a full lodash import catches the specific regression by identity, gives a legible error message, and does not drift as the app legitimately grows. A size budget catches the unknown regressions; a forbidden set catches the ones you have paid to fix once and refuse to pay for again. Store the previous build’s total as a committed baseline or a CI cache artifact so the percentage comparison has something to compare against on the first run of a branch.
Gotchas & Edge Cases
- Treemap shows the output, not intent. A node sized at 40 KB tells you what survived, not why. The trap is patching the biggest rectangle because it is biggest, when the real defect is a small module whose retention drags in a large transitive subtree. Always cross-reference the import chain in the visualizer sidebar before patching: follow the edges backward to the source reference that keeps the node alive, and fix that. Removing the leaf without cutting the edge just moves the weight somewhere else in the next build.
gzipSize: truereorders your mental model. A node that looks large uncompressed may gzip to almost nothing (repetitive vendor code with long, compressible identifier runs), while a smaller node of dense, high-entropy application logic barely compresses at all. Sorting by raw size therefore sends you to optimize bytes the network never pays for. Sort by gzip when prioritizing fixes, since transfer size is what determines load time — and confirm the effort was worth it against the gzip column, not the raw one.open: trueis useless in CI. Headless runners have no browser to launch, and on some CI images the attempt hangs the step waiting on a display that never appears. Emit thejsontemplate and assert on it programmatically instead; keepopen: trueguarded behind a local-only condition so it never reaches the pipeline config. The human treemap and the machine JSON are separate concerns and should be wired to separate triggers.- Dual visualizer plugins write the same default filename. Both instances default to
stats.html, so when you emit both atreemapand ajsonreport without settingfilename, the second plugin silently overwrites the first and you are left with one artifact wondering where the other went. Set distinctfilenamevalues on every instance. The failure is silent — no error, just a missing file — which is exactly the kind of thing that wastes twenty minutes before you notice the JSON your CI script reads is actually HTML.
When the visualizer is the wrong tool
The treemap is a module-attribution tool; it answers “which modules occupy the bytes”. It is the wrong tool for questions of a different shape. If you need to know why a specific module was retained rather than that it was, Rollup’s own --verbose output and the moduleSideEffects logging tell you more than any picture. If the bloat is duplicate copies of the same dependency at different versions, a treemap will show two similarly-named nodes but a lockfile dedupe analysis (npm ls <pkg>) diagnoses it faster. And if the artifact is already minified beyond recognition and you need to map a surviving byte range back to original source, source-map-explorer reads the source map directly and attributes at the line level, which the visualizer’s module-granularity view cannot. Reach for the visualizer when the question is comparative bundle composition; reach elsewhere when the question is provenance or duplication.
Comparison with esbuild’s analyzer and source-map-explorer
Three tools cover overlapping ground with different trade-offs. rollup-plugin-visualizer is the richest for Rollup and Vite output because it reads Rollup’s bundle object directly and renders an interactive, gzip-aware treemap — but it only sees what Rollup produces, so it cannot analyze an esbuild-only build. esbuild --analyze (or metafile: true fed to esbuild’s analyzeMetafile) is the native option for esbuild and Turbopack-adjacent workflows; it prints a fast text breakdown and emits a metafile you can script against, at the cost of a less navigable presentation and no built-in gzip figure. source-map-explorer is bundler-agnostic — it works on any output that ships a source map, including a production bundle from a build you do not control — and attributes bytes at source-line granularity, which is unmatched for tracking a specific surviving function, but it depends entirely on source-map accuracy and says nothing about the module graph’s edges. Use the visualizer as the default for this stack, keep esbuild --analyze for esbuild pipelines, and fall back to source-map-explorer when you only have the shipped artifact and its map.
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.