What you'll learn
- Quick answer
- Why bundle bloat hides in plain sight
- Reading a treemap: stat size vs. parsed size vs. gzip size
- A real before-and-after: one import statement, real numbers
- What's usually actually taking up the space
- Finding duplicate dependencies specifically
- The right diagnostic tool for your bundler
- Making this a habit instead of a one-time fire drill
- FAQ
Quick Answer
Finding what's bloating a JS bundle means reading a treemap from a tool like webpack-bundle-analyzer, rollup-plugin-visualizer, or source-map-explorer, which maps compiled bundle bytes back to the source module that produced them. In a real test build, switching one import statement from the whole lodash library to a single function cut the gzipped bundle from 27.45 KiB to 9.94 KiB — a 2.76x reduction from code that was never even called.
Why bundle bloat hides in plain sight
Tree shaking and code splitting are both about what a bundler does with your code once you've written it — removing dead exports, deferring routes into separate chunks. Neither one tells you what's actually sitting inside the bundle you just shipped. You can have a perfectly configured bundler with tree shaking and route-based splitting both working correctly, and still ship 200KB of a charting library because one component imports the whole thing for a single small function.
Diagnosis is a different skill from configuration, and it requires a different kind of tool: something that takes your actual compiled output — the file the browser downloads — and maps every byte of it back to the source module that produced it. That's what a bundle analyzer does, and the rest of this article is a real, executed walkthrough of using one, not a description of what one theoretically shows.
Reading a treemap: stat size vs. parsed size vs. gzip size
Every bundle analyzer reports (at minimum) three different numbers for each module, and confusing them is the single most common reason people misread these reports:
- Stat size — the size of the module's source as webpack counted it before minification. Useful for spotting which source files are large, but not what actually ships.
- Parsed size — the size after minification, the literal byte count of what's written to your output file on disk. This is what your build artifact actually weighs.
- Gzip size — the size after gzip (or brotli) compression, which is what the browser actually downloads over the network if your server or CDN compresses responses (nearly all do by default for JS).
Gzip size is normally the number to optimize for, since it's the real network cost. But it compresses differently depending on how repetitive the code is — two modules with identical parsed size can have very different gzip sizes — so parsed size still matters for parse/execution cost on the client, which gzip size doesn't capture at all.
A real before-and-after: one import statement, real numbers
To make this concrete, here's an actual experiment: a tiny entry file that sorts an array using lodash and formats a date using dayjs, bundled with webpack and analyzed with webpack-bundle-analyzer. Version A imports the whole lodash library the way most tutorials write it:
import _ from 'lodash';
import dayjs from 'dayjs';
console.log(_.sortBy(users, 'age'));
console.log(dayjs().format('YYYY-MM-DD'));Real webpack build output for that version:
asset bundle.js 76.4 KiB [emitted] [minimized] (name: main)
cacheable modules 540 KiB
./src/entry.js 212 bytes [built]
./node_modules/lodash/lodash.js 533 KiB [built]
./node_modules/dayjs/dayjs.min.js 6.99 KiB [built]
webpack 5.111.1 compiled successfully533 KiB of source for a single sortBy() call. The final minified bundle comes out to 76.4 KiB, which the analyzer's own JSON report puts at 27.45 KiB after gzip. Now the fix — importing only the specific function actually used:
import sortBy from 'lodash/sortBy.js';
import dayjs from 'dayjs';
console.log(sortBy(users, 'age'));
console.log(dayjs().format('YYYY-MM-DD'));Real webpack build output for that version:
asset bundle.js 28.5 KiB [emitted] [minimized] (name: main)
modules by path ./node_modules/lodash/*.js 97.9 KiB
./node_modules/lodash/sortBy.js 1.63 KiB [built]
./node_modules/lodash/_baseFlatten.js 1.17 KiB [built]
./node_modules/lodash/_baseOrderBy.js 1.52 KiB [built]
+ 126 modules
./src/entry.js 225 bytes [built]
./node_modules/dayjs/dayjs.min.js 6.99 KiB [built]
webpack 5.111.1 compiled successfullyWebpack still traces 126 small internal lodash helper files (that's how lodash is structured internally), but the total is 97.9 KiB of source instead of 533 KiB, and the final minified bundle drops to 28.5 KiB — 9.94 KiB gzipped. Same function call, same output, one import statement changed: a 2.76x reduction in what actually goes over the wire, and the full number came straight out of the analyzer's report, not an estimate.
What's usually actually taking up the space
The lodash example above is one instance of a broader pattern: whole-library default imports of large utility packages (lodash and moment.js are the two classic offenders — moment in particular bundles locale data that most apps never touch). Beyond that single pattern, a few other culprits show up repeatedly in real treemaps:
- Duplicate dependency copies — two different packages in your tree depend on incompatible semver ranges of the same library, so your bundler includes two full copies of it. You'll see this as two same-sized blocks with the same package name at different
node_modulesnesting depths. - Polyfills for browsers you no longer support — an old or overly conservative
browserslistconfig can silently pull in transpilation helpers and polyfills for browser versions your actual analytics show almost no traffic from. - Accidentally bundled server-only code — a shared module imports a Node-only SDK (one that needs
fsor native crypto bindings) for a code path that only actually runs server-side, but because the import is unconditional, the client bundle pulls in the whole thing, sometimes with bundler-generated Node shims that inflate it further. - Barrel-imported icon or component libraries — importing a whole icon set as one named import when only three icons are used, instead of importing each icon from its own submodule path the way the lodash fix above does.
Finding duplicate dependencies specifically
When a treemap shows the same package name in two different places, confirm it with the package manager directly rather than guessing from the visual:
npm ls lodashThis prints every copy of the package in your dependency tree along with which parent package pulled in each version, which tells you exactly which dependency to update, override, or replace. Once you've identified the offending version mismatch, npm dedupe will collapse compatible duplicates automatically where semver allows it; where it doesn't (two packages genuinely require incompatible major versions), the fix usually means using npm's overrides field in package.json (or pnpm's resolutions-equivalent) to force a single version, after confirming that version actually satisfies both dependents.
The right diagnostic tool for your bundler
The tool you reach for depends on what built the bundle:
- webpack —
webpack-bundle-analyzer, used for the real example above. Add it as a plugin withanalyzerMode: 'static'to generate a standalone treemap HTML report on every build instead of opening a live server. - Vite or Rollup —
rollup-plugin-visualizer, added to the plugins array invite.configwith options likegzipSize: trueandbrotliSize: trueto get the same treemap-with-real-byte-counts view for a Vite build's output. - Any bundler, given a standard source map —
source-map-explorer, run directly against a built file and its.map:npx source-map-explorer dist/bundle.js, with flags like--htmlfor a saved report or--gzipto sort by compressed size.
One honest note from actually running these tools while researching this article: pointing source-map-explorer at a minified esbuild bundle produced a real parsing error ("generated column Infinity... but the source only contains N columns") rather than a report. Not every bundler's source map output is compatible with every analyzer's parser. When that happens, the fix isn't to fight the tool — it's to reach for the bundler's own native analyzer plugin instead, which is exactly why the working example in this article uses webpack-bundle-analyzer against a webpack build rather than forcing a mismatched combination to cooperate.
Making this a habit instead of a one-time fire drill
Bundle analysis is most useful as a recurring check, not a one-time audit you run after someone complains the site feels slow — by the time that complaint arrives, several unrelated dependency additions have usually piled up, and untangling which one is actually responsible takes far longer than catching each one individually would have. Tools like size-limit or bundlesize plug into CI and fail a pull request outright when a named output file crosses a byte budget you've set, which catches a regression at the exact commit that introduced it.
At minimum, get in the habit of re-running your analyzer any time you add a new dependency, before merging, rather than trusting that a package's advertised "lightweight" claim on npm actually holds once it's been through your specific bundler and import pattern. The lodash example above is proof that the same library can cost 76.4 KiB or 28.5 KiB minified depending entirely on how you import it — the package name alone tells you almost nothing.
