How to Analyze Vite Bundles with rollup-plugin-visualizer
This guide covers the main analysis tool for Vite & Rollup Build Optimization, part of JavaScript Bundle Optimization & Code Splitting. Before changing chunking or replacing a dependency, you need to see what is in each chunk, how large it is in the units that matter, and why it is there. rollup-plugin-visualizer produces that picture from the Rollup build that Vite runs in production.
Reading the output correctly matters as much as generating it. The plugin reports several sizes — rendered (after tree-shaking, before minification in some setups), gzip and brotli — and offers several views. Teams that only look at the biggest rectangle in the treemap often optimise the wrong thing: a library that is large in total but only on a lazy route, while a smaller library on the critical path does more damage to LCP and INP.
Rapid Diagnosis
- Generate the report on a production build. The plugin only reflects what Rollup bundles; the dev server shows nothing useful.
- Start from the route, not the biggest box. Identify which chunks your landing route loads (Network panel or the Vite manifest), then inspect those.
- Use gzip or brotli size for transfer, rendered size for parse cost. Both matter; they tell different stories.
- Look for duplicates. The same package name appearing under several chunks or at several versions.
- Look for surprises. Locale files, development builds of libraries, test utilities, or full icon sets in production chunks.
Root Cause Analysis: What Analysis Usually Reveals
1. Libraries imported for one function. A date or utility library included wholesale because of a namespace import or a non-tree-shakeable build.
2. Large dependencies on the critical path. A rich-text editor or chart library statically imported by a component rendered on the landing route.
3. Duplicate versions. Two major versions of a library pulled in by different dependencies, both shipped.
4. Assets inlined as JavaScript. SVG icon sets, JSON data or images converted to base64 inside JS chunks, inflating parse cost.
Step-by-Step Workflow
1. Configure the plugin for useful output
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
filename: 'dist/stats.html',
template: 'treemap', // also: 'sunburst', 'network', 'raw-data', 'list'
gzipSize: true,
brotliSize: true,
emitFile: false,
}),
],
};
// trade-off: computing gzip and brotli sizes slows large builds noticeably.
// Enable the plugin only in an "analyze" build mode rather than on every build.
ANALYZE=1 npx vite build && open dist/stats.html
# trade-off: opening the report locally is fine; publishing stats.html with your
# site exposes your dependency list and file paths. Keep it out of the deploy.
Expected outcome: an interactive report you can filter by chunk and module.
2. Use the right view for the question
- Treemap — what is big in each chunk.
- Sunburst — how a package's size is distributed across its internal files (useful for libraries with many submodules).
- Network — which modules import which, to answer "why is this here?".
- Raw data / list — machine-readable output for CI comparisons.
3. Trace why a module is included
In the network view, select the surprising module and follow its importers back to your code. The first import from your own source is where to intervene — replace a namespace import, change a barrel import to a direct path, or turn a static import into a dynamic one.
// Before: pulls the whole icon set into the landing chunk.
import * as Icons from '@acme/icons';
// After: import only what the component renders.
import { SearchIcon } from '@acme/icons/search';
// trade-off: deep imports couple you to a package's internal file layout,
// which can change in minor versions. Prefer packages that publish
// per-icon entry points or sideEffects-free ESM that tree-shakes reliably.
Expected outcome: the module disappears from the chunk, or moves to the lazy chunk where it belongs.
4. Compare reports between builds in CI
Emit raw-data JSON on the main branch and on each pull request, and post a size diff per chunk as a PR comment. Large unexplained growth is caught before merge.
// scripts/diff-stats.mjs — compare per-chunk brotli totals from two raw-data reports.
import base from './base-stats.json' with { type: 'json' };
import head from '../dist/stats.json' with { type: 'json' };
function perChunk({ nodeMetas, nodeParts }) {
const out = {};
for (const meta of Object.values(nodeMetas)) {
for (const [chunk, partUid] of Object.entries(meta.moduleParts)) {
out[chunk] = (out[chunk] ?? 0) + (nodeParts[partUid]?.brotliLength ?? 0);
}
}
return out;
}
const before = perChunk(base), after = perChunk(head);
for (const chunk of new Set([...Object.keys(before), ...Object.keys(after)])) {
const delta = (after[chunk] ?? 0) - (before[chunk] ?? 0);
if (Math.abs(delta) > 2048) console.log(`${chunk}: ${delta > 0 ? '+' : ''}${(delta / 1024).toFixed(1)} KB brotli`);
}
// trade-off: the raw-data schema is internal to the plugin and changes between
// major versions. Pin the plugin version, or use size-limit for stable CI output.
Expected outcome: reviewers see "landing chunk +38KB brotli" next to the code that caused it.
Verification
After each change, rebuild and confirm the module has left the critical-path chunk or shrunk, then measure the route in a throttled lab run: JavaScript transfer size, scripting time and TBT should all fall. Keep the before and after reports; they make the change easy to explain in code review and in a performance changelog.
Reading Sizes Correctly
The three sizes answer different questions. Rendered size is the module's code as it appears in the output chunk before compression; it is the best proxy for parse and compile time. Gzip and brotli sizes estimate bytes on the wire; brotli is usually what modern CDNs serve. A module that compresses exceptionally well — repetitive JSON, generated code — can look small in brotli and still cost significant parse time. Conversely, already-minified, high-entropy code compresses less. When the goal is LCP on slow networks, prioritise brotli size on the critical path; when the goal is INP and TBT on slow CPUs, prioritise rendered size.
Common Findings and Their Fixes
- Full locale sets (moment, date libraries, i18n bundles): switch to modular date libraries or load locales dynamically per user language.
- Development builds in production: a library resolved through a
browserordevelopmentcondition — checkresolve.conditionsandNODE_ENVdefines. - Polyfills for features every target supports: tighten Browserslist and the build target.
- JSON or SVG inlined into JS: load large data with
fetchand import SVG as URLs rather than components, unless they are tiny. - Two copies of the framework: usually a linked local package with its own
node_modules; useresolve.dedupe.
FAQ
Why does the visualiser total differ from the files in dist?
The plugin measures module contributions within chunks, sometimes before final minification or without Rollup's runtime helpers, and its gzip estimates compress each module separately rather than the whole file. Treat its numbers as relative — which modules are large — and use the actual emitted files for absolute budgets.
Is vite-bundle-visualizer different?
It is a thin CLI wrapper that runs your Vite build with rollup-plugin-visualizer configured, so you do not need to edit your config. The output and views are the same.
Can it analyse the SSR bundle?
Yes, if the plugin runs during the SSR build too. The server bundle's size rarely matters for Core Web Vitals, but analysing it can reveal code accidentally shared with the client or dependencies that should be externalised.
Related
- Configuring manualChunks in Vite — acting on what the treemap shows.
- How to configure webpack-bundle-analyzer for production — the webpack counterpart.
- Finding bundle bloat with source-map-explorer — a bundler-agnostic view from source maps.