Reading webpack-bundle-analyzer Treemaps
Scenario: a teammate opens dist/report.html after a routine feature merge and finds the main chunk has grown from 640 KB to 1.1 MB uncompressed. The treemap renders — dozens of rectangles of every size, arranged in an unfamiliar layout — but nothing about the picture immediately explains where the extra 460 KB came from. The build log gave no warning. npm run build finished clean. The only lead is this treemap, and right now it looks like noise.
dist/
main-8f2c1a.js 1 102 KB (uncompressed)
vendor-4b7de9.js 312 KB
runtime-a91c3d.js 4 KB
This is a common moment in a bundle-size investigation: the report exists, but reading it correctly — knowing which rectangle to trust, which size metric to look at, and how deep to drill — is a separate skill from generating it.
Root cause: treemap area is not the same thing at every zoom level
This page sits under the visualizing bundle composition with analyzers cluster, which covers all three major analyzer tools; this page focuses specifically on getting an accurate read from webpack-bundle-analyzer’s treemap once it is already open in front of you.
The treemap nests rectangles hierarchically: the outermost boxes are emitted chunks (main, vendor, any named async chunk), the next level down is typically the top-level node_modules package, and the innermost boxes are individual files within that package. A rectangle’s area is proportional to its byte size relative to its siblings at the same nesting level — which means a rectangle that looks enormous inside a small chunk may still be smaller in absolute bytes than a modest-looking rectangle inside a much larger chunk. Reading area as an absolute measurement, rather than a relative one within its own chunk, is the single most common misread of a treemap.
The second common misread is the size mode. webpack-bundle-analyzer renders three interchangeable measurements for every rectangle, and the initial view depends on how the plugin was configured:
- Stat size — pre-minification size straight from the webpack module graph. Large for verbose source, irrelevant to what a browser downloads.
- Parsed size — post-minification, pre-compression. This is the number that correlates with JS engine parse and compile cost.
- Gzip size — parsed output compressed the way a production server serves it. This is the number that correlates with network transfer time and is the right one to budget against.
A module can look identical in stat size to another and diverge sharply once parsed and gzipped — dense, already-minimal library code compresses less than repetitive application code, so the ranking of “biggest offender” can change completely between size modes. Always confirm a finding in at least two of the three modes before concluding a module is the culprit.
The report also uses color to encode a second dimension of information: every top-level chunk gets its own hue, and files within that chunk are rendered as shades of the same hue, darker for larger relative size within their immediate parent. This means color alone tells you which chunk a rectangle belongs to even after zooming several levels deep, which matters once a drill-down has scrolled the original chunk boundary out of view. It does not encode file type or dependency freshness — a common assumption that leads people to look for a “red is bad” pattern that the tool was never designed to show.
Why unlabeled chunks make this investigation harder than it needs to be
Before drilling into main-8f2c1a.js in the scenario above, it is worth asking why the chunk is named main at all rather than something that hints at its contents. Webpack names entry chunks after the entry key in module.exports.entry, and it names dynamic import chunks after either a numeric module ID or an explicit webpackChunkName comment. A codebase with dozens of import() calls and no naming convention produces a treemap full of chunks named 3.js, 47.js, and 112.js — technically drillable, but meaningless at a glance. Establishing a naming convention before an investigation starts (rather than during one) turns “which of these forty numbered chunks is the billing dashboard” into a one-second lookup.
Exact configuration to generate a drillable report
If the report currently open was generated with defaults, regenerate it with an explicit configuration so the size mode and file naming are predictable for the next investigation:
// webpack.config.js — Webpack 5
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
mode: 'production',
devtool: 'hidden-source-map', // Keeps source maps on disk for later attribution
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // Static HTML file — safe for CI and for sharing
reportFilename: 'report.html',
openAnalyzer: false,
defaultSizes: 'parsed', // Open on the metric that matters for CPU cost
excludeAssets: [/\.map$/], // Don't clutter the treemap with map file entries
})
]
};Run the build and open the report directly from disk — no server is required for analyzerMode: 'static':
npx webpack --config webpack.config.js
# then open the generated file directly, e.g.:
open dist/report.html # macOS
xdg-open dist/report.html # LinuxIf a stats.json already exists from a CI run and you only need to view it, skip the rebuild entirely:
npx webpack-bundle-analyzer stats.json --mode static --report report.htmlStep-by-step verification checklist
-
Confirm the report was built from a production, not development, config. Development builds skip minification, so parsed size will be misleadingly close to stat size and gzip size will be misleadingly small relative to what ships. Check the top of
report.htmlfor amode: productionindicator, or re-run the build with--mode productionexplicitly. -
Switch the size selector to parsed, then to gzip. Most webpack-bundle-analyzer builds render a dropdown or radio control in the top corner of the report. Note the top three largest rectangles under each mode — if the ranking changes noticeably between parsed and gzip, the biggest-looking module by parsed size may compress unusually well and not be the real priority.
-
Click into the largest chunk, then the largest package inside it, then the largest file inside that package. Each click zooms the treemap to that sub-tree, re-scaling rectangle areas relative to the new parent. Stop drilling once a single file — not a directory or package aggregate — accounts for a clear plurality of the remaining area.
-
Hover the offending file for its exact byte counts. The tooltip shows stat, parsed, and gzip size simultaneously for that one rectangle, along with its full module path relative to the project root.
-
Search the treemap for the same package name appearing more than once. Use the browser’s find-in-page (Ctrl/Cmd+F does not search inside the SVG/canvas render, so use the analyzer’s own search box if present, or scan visually within each chunk). A package appearing under both
mainandvendorat a similar size is frequently a duplicate-version issue rather than a legitimately large dependency — see finding duplicate dependencies in a bundle for the lockfile-level diagnosis. -
Cross-reference the file path against recent commits. Once a specific file is identified — for example
node_modules/@some-scope/charting-lib/dist/index.jssitting at 340 KB parsed insidemain— checkgit log --oneline -- package.jsonaround the date the chunk grew to confirm which dependency bump introduced it. -
Re-run the build after a fix and diff the two reports. Keep the original
report.htmlandstats.jsonbefore making changes; after applying a fix (code-splitting the dependency into its own async chunk, switching to a lighter alternative, or applying sideEffects and tree-shaking configuration), regenerate and confirm the specific rectangle shrank or disappeared.
Edge cases and gotchas
Gotcha 1 — a single leaf rectangle with no further nesting
Some rectangles cannot be drilled into further because the bundler received the file as one opaque module — a pre-bundled UMD library, a WASM glue file, or a generated data file like an icon font mapping. If a 200 KB leaf rectangle shows no children, the fix has to happen outside the treemap: either replace the dependency with a modular alternative that ships separate ES modules, or confirm whether the library offers a tree-shakeable ESM build under a different entry point in its exports map.
Gotcha 2 — concatenated modules hide inside a single rectangle
When optimization.concatenateModules (scope hoisting) is enabled, webpack merges multiple small modules into one function scope in the output bundle. The bundler’s own stats still record them as separate modules, so the treemap usually still nests them correctly — but if a report was generated from a build rather than webpack’s internal module records, some third-party tooling downstream of stats.json can lose that granularity. If a rectangle looks unexpectedly monolithic, regenerate stats.json fresh from the same build rather than relying on a cached or hand-edited copy.
Gotcha 3 — chunk names change between builds, making history hard to compare
Without deterministic output naming, a chunk that was 847.js in one build might be 912.js in the next, even though its contents are the same. Comparing two treemaps across builds is far easier once chunk output is stable — the deterministic chunk hashing for long-term caching guide covers the exact optimization.moduleIds and optimization.chunkIds settings that keep a given chunk’s identity stable across builds so its treemap rectangle can be tracked release over release.
Gotcha 4 — the treemap looks fine but the deployed page is still slow
If the treemap shows a reasonable gzip size but real users still experience a slow load, the bottleneck may not be bundle composition at all — check whether the chunk is being requested in a waterfall rather than in parallel. That is a loading-strategy problem, not a composition problem, and is covered separately in preventing waterfall requests with dynamic import maps.
Gotcha 5 — scoped packages nest one level deeper than expected
Scoped packages such as @my-org/ui-kit add an extra path segment (node_modules/@my-org/ui-kit/...) compared to unscoped packages (node_modules/lodash/...). In the treemap, this shows up as an extra nesting level — the @my-org scope itself renders as a rectangle containing every scoped package your project depends on, before you reach the individual package boundary. When scanning quickly for “which package is largest,” it is easy to misread the scope-level aggregate as a single dependency rather than a folder containing several. Expand one level further before comparing sizes across scoped and unscoped dependencies.
FAQ
Why does the treemap show different sizes than my Network tab?
The Network tab reports the transferred size for a specific request, which reflects your server’s actual compression settings and any CDN-level recompression. The treemap’s gzip mode simulates standard gzip at a fixed compression level. If your server uses Brotli or a different gzip level, the two numbers will diverge by a few percent — that gap is normal and not a bug in either tool.
Why is one file taking up almost the entire treemap with no further nesting?
A flat, wide rectangle with no children usually means the file is a single large pre-bundled artifact — a vendored, already-minified library shipped as one file, a bundled WASM loader, or a large generated data file such as an icon font or locale table. Because webpack-bundle-analyzer can only see module boundaries the bundler recorded, a file that was never split into smaller modules shows up as one leaf rectangle regardless of its internal structure.
Can I use webpack-bundle-analyzer without rebuilding, using an existing stats.json?
Yes. The webpack-bundle-analyzer CLI accepts a stats.json path directly: npx webpack-bundle-analyzer stats.json. This is useful for reviewing a report generated by CI without re-running the build locally, as long as the stats.json was generated with the same production configuration you want to analyze.
Related
- Visualizing Bundle Composition with Analyzers — parent cluster comparing webpack-bundle-analyzer, rollup-plugin-visualizer, and source-map-explorer
- Setting Up rollup-plugin-visualizer in Vite — a companion guide for teams on Vite who want the equivalent treemap
- Finding Duplicate Dependencies in a Bundle — how to confirm a treemap-flagged suspect is actually a duplicate package version
- Stabilizing Chunk Hashes to Maximize Cache Hits — keeping chunk identity stable so treemaps stay comparable across builds
- How to Audit sideEffects in Large npm Packages — the fix once a treemap points at an over-retained dependency