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.

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.

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

Emit both a treemap and a JSON report A production build with two visualizer instances emits stats.html for human inspection and stats.json for CI assertions; both must come from a production build because the dev server never tree-shakes. production build--mode production stats.html (treemap)human inspection stats.jsonCI assertions
Figure: emit both templates — the treemap for eyes, the JSON for the CI gate.

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.

Three treemap failure signatures Ghost modules are entire libraries retained despite importing one export; unpruned side-effects are modules flagged sideEffects true that bypass elimination; and incorrect polyfills are legacy shims bundled by preset-env targeting or implicit require. ghost moduleswhole lib for one exportbarrel / no ESM entry unpruned side-effectssideEffects: truebypasses elimination incorrect polyfillspreset-env targetingimplicit require() Three heavy-node shapes and what each means
Figure: three distinct treemap symptoms, each pointing at a different root cause below.

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 debounce and the whole of lodash is 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 one index.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: true in package.json bypass 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’s package.json for a missing or overly broad sideEffects field, then override it as shown below.
  • Incorrect Polyfills: Legacy shims bundled due to @babel/preset-env targeting or implicit require() calls. The symptom is core-js internals, regenerator runtime, or moment locale files appearing in a modern-browser build. The root cause is usually a browserslist target broader than your real audience, which instructs preset-env to inject transforms and their supporting shims. Confirm by checking the effective browserslist and whether useBuiltIns: '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.

Three patterns that defeat static analysis CJS interop fallbacks make Rollup bundle the whole export object; a missing or over-broad sideEffects field forces retention; and a dynamic import with a variable path cannot be resolved at compile time. CJS interop fallbackdynamic module.exports→ whole object bundled sideEffects wrongmissing or over-broad glob→ every file kept variable import()interpolated path→ can't resolve graph Architectural patterns, not bundler bugs
Figure: three source-code patterns — each removes the static guarantee tree-shaking depends on.
  1. 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 to exports.foo and module.exports.foo. The moment a package assigns exports dynamically — exports[name] = fn inside a loop, or a computed Object.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.
  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. The field is a contract: false promises 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.
  3. 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 literal import() 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.

Four targeted remediations Patch the package.json sideEffects field, refactor dynamic imports to a static lookup map, configure Rollup treeshake for known-safe externals, then rebuild and compare the treemap against the baseline. 1 patch sideEffectsscope it 2 static import mapno interpolation 3 treeshake optsknown-safe 4 rebuild + diff>15% target
Figure: each remediation targets one signature; step 4's diff proves the fragment happened.

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 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. 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.

The CI gate reads the JSON report A CI step runs the production build and a check script that parses stats.json, failing the pipeline if total size exceeds the budget or any forbidden module reappears. build → stats.json check-bundle.jssize + forbidden set pass / fail PRregression → exit 1
Figure: the JSON template feeds a script that fails the build on a size or forbidden-module regression.

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

Four visualizer edge cases The treemap shows output not intent so cross-reference the import chain; gzipSize reorders priorities because uncompressed size misleads; open true is useless in CI; and two visualizer instances with the same filename overwrite each other. output, not intent a 40KB node says what survived, not why — read the import-chain sidebar. gzipSize reorders priorities a big uncompressed node may gzip to nothing — sort by gzip. open: true useless in CI headless runners have no browser — emit json and assert on it. duplicate filenames overwrite two visualizer instances need distinct filename values or the second wins.
Figure: two "read it correctly" traps and two "it silently did the wrong thing" traps.
  • 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: true reorders 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: true is 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 the json template and assert on it programmatically instead; keep open: true guarded 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 a treemap and a json report without setting filename, the second plugin silently overwrites the first and you are left with one artifact wondering where the other went. Set distinct filename values 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.