Visualizing Bundle Composition with Analyzers

A production JavaScript bundle rarely announces why it grew from 220 KB to 480 KB uncompressed between two releases — the build simply gets slower to load, and nobody can point at the module responsible without a visual breakdown. Bundle analyzers turn an opaque module graph into a treemap or sunburst chart where rectangle area is proportional to byte size, making it possible to spot the one dependency responsible for 35% of an initial payload in seconds rather than hours of grep-ing through node_modules.

This matters because the gap between a naive and a well-optimised bundle is large and quantifiable: naive production builds commonly ship 400–600 KB of uncompressed initial JavaScript, while a well-tuned target sits under 150 KB gzipped. On a mid-tier mobile device under 4× CPU throttling, that gap alone accounts for 800–1200 ms of avoidable Time to Interactive — bytes the device has to download, parse, and execute before the page responds to input. Closing that gap without a visualizer means guessing which of dozens of dependencies to touch first; with one, it means reading a chart, identifying the two or three rectangles that dominate the payload, and acting on exactly those.

Most teams discover their bundle has grown only after a Lighthouse score drops or a support ticket mentions a slow page load — by which point the regression may be several releases old and buried under unrelated commits. A treemap generated on every build, by contrast, turns bundle composition into something reviewable at the same cadence as the code itself, catching a 40 KB regression the day it lands rather than the month a performance audit finally happens.

Why a treemap beats manual auditing

This page sits inside the broader bundle analysis and performance budgets section, which treats visualization as the first of three disciplines — the other two being CI budget enforcement and source-map-based attribution. Visualization comes first because you cannot set a meaningful budget, or attribute a regression to a specific commit, until you can see what the bundle is actually made of.

Manually auditing a bundle means reading stats.json as raw JSON, or grepping build output for file sizes, and mentally reconstructing a hierarchy. That approach scales poorly past a few dozen modules. A treemap instead encodes the entire module graph as nested rectangles: the outer rectangle is the chunk, inner rectangles are the modules inside it, and rectangle area is proportional to byte size — so the biggest problems are, quite literally, the biggest shapes on the screen. A sunburst chart encodes the same hierarchy radially, trading absolute area comparison for a clearer sense of nesting depth, which is useful when a dependency imports another dependency several layers deep.

Three tools cover the vast majority of real-world setups: webpack-bundle-analyzer for Webpack 5 stats output, rollup-plugin-visualizer for Vite and Rollup, and source-map-explorer for attributing bytes back to original source files regardless of which bundler produced the map. Each reads a different artifact and answers a slightly different question.

Generating the underlying artifact

Every analyzer needs raw data before it can draw anything. Webpack emits that data as a stats JSON file; Vite/Rollup emits it as a build metafile computed from the internal module graph during the same build pass that produces the bundle.

Webpack 5 — stats.json and webpack-bundle-analyzer

// webpack.config.js — Webpack 5
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  mode: 'production',
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',           // Write an HTML report instead of launching a server
      reportFilename: 'report.html',    // Relative to the output directory
      openAnalyzer: false,               // Do not auto-open a browser tab in CI
      generateStatsFile: true,           // Also emit a raw stats.json for other tooling
      statsFilename: 'stats.json',
      defaultSizes: 'parsed'             // Initial view mode: stat | parsed | gzip
    })
  ]
};

Running webpack --config webpack.config.js with this plugin attached produces both dist/report.html (the interactive treemap) and dist/stats.json (the raw module graph, useful for scripted diffing or feeding into source-map-explorer). The defaultSizes option controls which of the three metrics the treemap opens with — set it to parsed for day-to-day debugging, since that is closest to what the JS engine actually tokenizes.

Vite 5+ — rollup-plugin-visualizer

// vite.config.js — Vite 5+
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  build: {
    rollupOptions: {
      plugins: [
        visualizer({
          filename: 'dist/report.html',
          template: 'treemap',       // treemap | sunburst | network | list
          gzipSize: true,             // Show gzip size alongside raw size
          brotliSize: true,           // Also compute brotli size for CDNs that prefer it
          emitFile: false             // Write next to the config, not inside dist/ assets pipeline
        })
      ]
    }
  }
});

Because rollup-plugin-visualizer runs as a Rollup plugin rather than a Webpack plugin, it has direct access to the module graph Vite’s production build already constructs — there is no separate stats-emission step to wire up. The setting up rollup-plugin-visualizer in Vite guide covers every template option and the CI artifact-upload pattern in depth.

source-map-explorer — attribution independent of bundler

# Works against either Webpack 5 or Vite 5+ output, provided source maps exist
npx source-map-explorer 'dist/assets/*.js' --html report-explorer.html

source-map-explorer ignores the bundler’s own notion of “module” and instead walks the emitted .js.map files, attributing every byte in the final .js output back to its original source file. This is the most trustworthy view when a bundler’s scope hoisting or module concatenation has merged dozens of small files into what looks, from the bundler’s stats, like a single opaque blob.

Reading the treemap: stat, parsed, and gzip sizes

The three size metrics answer different questions, and confusing them is the single most common misread of a bundle report:

  • Stat size — the size of the module as recorded in the bundler’s internal graph, before minification. Useful for understanding how much source a dependency contributes, independent of build settings.
  • Parsed size — the size after minification but before network compression. This is what the JavaScript engine has to tokenize, parse, and compile, so it is the right metric for CPU-bound performance questions on low-end devices.
  • Gzip size — the parsed output compressed the way a production server or CDN would serve it. This is the right metric for network-bound questions and the one performance budgets should be written against, because it matches what actually crosses the wire.

A dependency that looks enormous by stat size (verbose source, generous whitespace, TypeScript type-only exports) can shrink dramatically once minified and compressed, while a small-looking dependency written in dense minified code already may barely shrink further. Toggle all three views before concluding a module is “the problem” — a common mistake is optimising for stat size, which has almost no correlation with what a user’s browser downloads or executes.

The stats-to-report pipeline

Bundle analysis pipeline: build artifacts to unified treemap report Three build artifacts on the left — Webpack 5 stats.json, emitted source maps, and a Vite/Rollup metafile — each feed a matching analyzer tool in the middle column: webpack-bundle-analyzer, source-map-explorer, and rollup-plugin-visualizer respectively. All three converge on the right into a unified treemap report, which is then archived as a CI artifact for size-diffing on pull requests. Webpack 5 stats.json --json=stats.json module graph dump webpack-bundle-analyzer treemap view stat · parsed · gzip toggle static HTML output Source maps .js.map files devtool: hidden-source-map emitted per chunk source-map-explorer per-file byte attribution bundler-agnostic Vite / Rollup module metafile rollupOptions.plugins built in-process rollup-plugin-visualizer treemap · sunburst · network templates static HTML output Unified Treemap Report (HTML) rectangle area = module byte size color = file type / chunk owner reviewed by engineer CI artifact + size diff

Each analyzer solves the same underlying visualization problem from a different data source, but they converge on the same output shape: a reviewable HTML report that should be archived on every pull request, not just generated ad hoc on a developer’s laptop. Archiving the report is what turns visualization into a repeatable process rather than a one-off investigation, and it is the natural hand-off into enforcing performance budgets in CI.

Choosing between treemap, sunburst, and network templates

rollup-plugin-visualizer exposes three rendering templates, and webpack-bundle-analyzer supports the treemap layout exclusively — understanding when the extra templates earn their keep saves time switching between views mid-investigation.

  • Treemap — the default and the right first choice almost always. Rectangle area maps directly to byte size, so absolute comparisons between two dependencies are trivial: a rectangle twice as wide represents roughly twice the bytes. This is the layout used throughout this guide and the one most engineers should reach for by default.
  • Sunburst — the same hierarchy rendered as concentric rings instead of nested rectangles. Depth in the dependency tree becomes a radial distance from the center, which makes it easier to see how deep a problematic transitive dependency sits, at the cost of making absolute size comparisons harder — two arcs at different radii are harder to compare by eye than two rectangles.
  • Network — a force-directed graph showing which chunks share which modules. This view is the odd one out: it does not encode size at all, and instead answers “which of my entry points or lazy routes are pulling in this dependency independently,” which is the exact question you need answered when deciding whether a module belongs in a shared vendor chunk or should stay inlined.

For most day-to-day bloat hunting, stay on the treemap. Reach for sunburst when a deeply nested transitive dependency is the suspect, and reach for network only when the question is about chunk overlap rather than raw size — a scenario that comes up often when tuning Vite manualChunks for vendor isolation and verifying that a shared dependency did not accidentally get duplicated across two vendor groups.

Framework integration: seeing chunk boundaries you created

Bundle analyzers become far more useful once the treemap’s chunk boundaries actually correspond to intentional splits in your application code, rather than one undifferentiated bundle.

React lazy/Suspense chunk naming

// RouteTree.jsx — React 18, Webpack 5 / Vite 5+
import { lazy, Suspense } from 'react';

// Named webpack chunk comment surfaces as a labeled rectangle in the treemap
const BillingDashboard = lazy(() =>
  import(/* webpackChunkName: "billing-dashboard" */ './routes/BillingDashboard')
);

function AppRoutes() {
  return (
    <Suspense fallback={<div>Loading billing…</div>}>
      <BillingDashboard />
    </Suspense>
  );
}

Without the webpackChunkName comment, Webpack names the emitted chunk after a numeric module ID, and it shows up in the treemap as an anonymous rectangle you have to click into to identify. With the comment, the treemap groups and labels it as billing-dashboard, making it immediately obvious how much a specific lazily-loaded route costs — a technique that pairs directly with the patterns in dynamic import patterns for on-demand loading.

Vue 3 defineAsyncComponent

// router/routes.js — Vue 3, Vite 5+
import { defineAsyncComponent } from 'vue';

const BillingDashboard = defineAsyncComponent(() =>
  import('./views/BillingDashboard.vue')
);

export default [
  { path: '/billing', component: BillingDashboard }
];

Vite/Rollup names async chunks after the imported file by default, so rollup-plugin-visualizer’s treemap already shows a readable BillingDashboard-[hash].js rectangle without extra annotation — one practical advantage of Rollup’s file-based chunk naming over Webpack’s numeric IDs when a project has not adopted explicit chunk name comments everywhere.

Quantified impact of adopting analyzer-driven review

  • Teams that add a treemap review step to their PR checklist typically catch duplicate-dependency regressions before merge, avoiding the 15–30 KB gzipped cost of a second copy of a common utility library shipping to production.
  • Switching a defaultSizes review habit from stat size to gzip size prevents an average of 1–2 false “this is fine” conclusions per audit cycle, since stat size routinely overstates verbose-but-minifiable source by 2–4×.
  • Combining treemap review with the barrel file elimination technique surfaces oversized aggregation modules as unusually wide, flat rectangles — a visual signature that is far faster to spot than reading import statements.
  • On mid-scale SPAs, routing chunk boundaries into named, analyzer-visible groups alongside vendor chunk isolation commonly explains the majority of a repeat visitor’s 75% bandwidth savings, because the treemap makes it obvious which rectangles are stable vendor code and which change every release.
  • Cross-referencing a treemap’s largest rectangles against deterministic chunk hashing output catches the specific failure mode where a large chunk’s hash changes on every build for no code reason, well before it erodes the >92% cache-hit target.

Common pitfalls when reading analyzer output

Most misreads of a bundle analyzer report are not caused by the tool itself but by the assumptions an engineer brings into the report. The five patterns below account for the majority of “the analyzer said it was fine but the bundle still regressed” incidents:

Failure mode Root cause Diagnostic signal Fix
“The bundle looks fine” despite a real regression Reviewing stat size instead of gzip size Treemap shows a small rectangle for a dependency known to have grown Set defaultSizes: 'gzip' (webpack-bundle-analyzer) or read the gzipSize column (rollup-plugin-visualizer) as the primary number
A huge unlabeled rectangle with no clear owner Missing webpackChunkName comments on dynamic imports Chunk labeled only by a numeric module ID in the treemap Add explicit chunk name comments to every import() call that should be independently reviewable
Same-sized rectangle appears twice under different paths Duplicate dependency versions resolved separately Two rectangles with identical internal composition, different node_modules path depth Deduplicate via resolve.dedupe (Vite) or resolve.alias (Webpack); confirm with finding duplicate dependencies in a bundle
Treemap generation hangs or the report never opens analyzerMode: 'server' used in a CI environment with no display Build process does not exit; CI job times out Use analyzerMode: 'static' (webpack-bundle-analyzer) or the default static output (rollup-plugin-visualizer) in any headless environment
source-map-explorer reports “no source map found” Production build stripped source maps or used devtool: false CLI exits with an explicit missing-map error per file Emit hidden source maps (devtool: 'hidden-source-map') so maps exist on disk without being referenced by a public //# sourceMappingURL comment

None of these pitfalls require a different tool — they require reading the same report with the right question in mind. A treemap answers “where are the bytes,” but it takes a deliberate habit of checking gzip size, chunk naming, and duplicate paths before that answer translates into the right fix.

Verification workflow

  1. Generate the report locally first. Run the analyzer against a production build (webpack --mode production or vite build), not a development build — development builds skip minification and will report misleadingly large parsed sizes.
  2. Open the treemap and toggle all three size modes. Confirm the chunk you are investigating is actually large under gzip size, not just stat size, before spending time on it.
  3. Cross-check in Chrome DevTools. Load the deployed page, open the Network panel, and confirm the transferred size for the chunk in question matches the analyzer’s gzip figure within a few percent — a large mismatch usually means a CDN compression or caching misconfiguration rather than a bundling problem.
  4. Archive the report as a CI artifact on every build. Store report.html and stats.json (or the Rollup metafile) as build artifacts so a size regression can be diffed against the previous commit’s report rather than re-generated from scratch during an incident.
  5. Escalate real findings into a budget. Once a treemap review identifies a specific chunk or dependency that should never regress past a given size, encode that finding as a size-limit entry so the next regression fails the build automatically instead of waiting for the next manual review.

FAQ

What is the difference between stat, parsed, and gzip size in a bundle analyzer?

Stat size is the size Webpack reports before any minification, taken directly from the module graph. Parsed size is the size after minification but before compression — what the browser actually parses. Gzip size is the parsed output compressed the way a production server would serve it over the wire. Always budget against gzip size, but debug bloat using parsed size, since that is what the JS engine has to tokenize and execute.

Should I use webpack-bundle-analyzer or source-map-explorer?

webpack-bundle-analyzer reads a stats.json module graph and is faster to generate, but it only sees module boundaries as the bundler recorded them. source-map-explorer reads the actual source map and attributes bytes down to the original source file, which is more accurate for bundles that went through heavy transpilation or concatenation. Use webpack-bundle-analyzer for a first pass and source-map-explorer to confirm attribution before filing a fix.

Does rollup-plugin-visualizer work the same way as webpack-bundle-analyzer?

Both render a treemap of module sizes, but rollup-plugin-visualizer reads Rollup’s internal module graph directly during the Vite build rather than a separate stats.json file, and it additionally supports sunburst and network diagram templates alongside the treemap. It does not have a live dev-server mode; it emits a static HTML file at build time.

Why does my treemap show a huge chunk with no obvious large dependency inside it?

This is almost always duplicate dependencies: two or more versions of the same package resolved into the same chunk, each contributing a similar-sized rectangle. Search the treemap for repeated package names at different path depths, then confirm with a lockfile audit or source-map-explorer’s per-file breakdown.