Bundle Analysis and Size Budgeting

A bundle grows one innocuous dependency at a time until a page ships a megabyte of JavaScript nobody meant to add. This guide treats bundle size as something you measure precisely and then defend with a budget: attribute every byte to a source module with a map-based analyzer, find duplicate copies of the same package, and wire a CI check that fails the build when a chunk crosses its limit. It sits under Core Concepts of Modern Bundling; the reduction techniques it measures are covered in tree-shaking mechanics and code-splitting strategies.

The problem exists because the numbers that matter are invisible at the moment they change. A pull request that adds import { format } from 'date-fns' looks like one line in review; what it actually does is pull a formatting function, its locale machinery, and whatever that function transitively imports into an entry chunk that a browser must download, parse, and compile before the page becomes interactive. Nobody sees the byte cost in the diff, the reviewer approves the feature on its merits, and the regression lands. Repeat that across a quarter of feature work and the entry chunk doubles without a single commit that anyone would identify as “the one that made the app slow.” Bundle size is a classic tragedy-of-the-commons metric: every contributor has an incentive to add, none has an incentive to measure, and the cost is paid by the user on a mid-range phone whom no contributor ever meets.

The fix has two halves, and they are not interchangeable. Analysis is diagnostic — it tells you, after the fact, which modules own the bytes, so you can decide what to remove or defer. Budgeting is preventive — it draws a line and refuses to let the build cross it, so the diagnosis you did once stays fixed. Teams that only analyze end up re-analyzing the same regression every few weeks; teams that only budget block the build on a number nobody can explain because they never learned to attribute it. You want both, in that order: attribute the bytes so you understand the shape of your bundle, fix the obvious waste, then set the budget just above where you landed so the next regression is caught by CI instead of by a user complaint. In the build pipeline this stage runs last, after bundling, tree-shaking, and code-splitting have all done their work, because it measures their combined output — the emitted chunks and their source maps — not any single transform in isolation.

From bundle to attributed bytes to an enforced budget A built bundle plus its source map is fed to an analyzer that attributes every byte to a source module; a duplicate-dependency scan finds packages included more than once; and a CI budget check compares chunk sizes against limits and fails the build when one is exceeded. Measure precisely, then defend with a budget bundle + mapthe built output analyzerbytes → source module duplicate scantwo copies of a pkg CI budgetover limit → fail attribution finds the byte; the budget stops it coming back
Figure: analysis attributes the bytes; the budget is what keeps them from silently returning.

Prerequisites

Analysis needs source maps in the build and a couple of analyzer tools. Pin them so output stays comparable across runs.

// package.json — verified tooling
{
  "devDependencies": {
    "vite": "5.4.0",
    "rollup-plugin-visualizer": "5.12.0",  // treemap of the Rollup graph
    "source-map-explorer": "2.5.3",         // attributes bytes via source maps
    "size-limit": "11.1.0"                   // CI budget enforcement
  }
}

Enable source maps (build.sourcemap: true in Vite) — every map-based analyzer is only as accurate as the maps it reads. Node 18+ is the floor for all three tools.

Pin the exact versions rather than a caret range. Analyzer output is a comparison over time — this week’s treemap only means something against last week’s — and a minor version bump in the visualizer or in Rollup can change how modules are grouped, how gzip is estimated, or how vendor chunks are named. If the tooling drifts underneath you, a size change you attribute to your own code may actually be a change in how the tool counts, and you will waste an afternoon chasing a regression that never happened. The same logic applies to the bundler itself: a Vite or Rollup upgrade can legitimately move the number, so treat those upgrades as deliberate re-baselining events, not routine dependency bumps. One more prerequisite that is easy to skip: build in production mode. Development builds are unminified, carry extra dev-only branches, and do not tree-shake the way a production build does, so a treemap of a dev bundle over-reports every module and under-reports nothing useful. Always analyze the artifact you actually ship.

Source maps are the input every analyzer depends on Attribution accuracy comes entirely from the build's source maps, so build.sourcemap must be enabled; without maps an analyzer can only report chunk totals, not which source module owns each byte. sourcemap: truein the build analyzer reads mapbytes → module accurateattribution no maps, no per-module attribution
Figure: every technique here degrades to chunk totals without source maps — enable them first.

Core mechanics: attribution, gzip, and duplication

An analyzer works backwards from the source map: each byte range in the output maps to an original source file, so summing those ranges attributes the chunk to modules and packages. Two numbers matter and are often confused — raw (parsed) size, which drives JavaScript parse/compile time, and gzip/brotli size, which drives transfer time. A dependency can be small over the wire but heavy to parse; budget both.

The mechanism is worth understanding because it explains every failure mode later. A source map is a JSON file with a mappings field encoded as base64 VLQ segments: each segment records a generated column in the output and the original file, line, and column it came from. The analyzer walks the generated bundle position by position, decodes the segment that covers each position, and adds that byte to a running total keyed by the original file. Sum those totals per file, roll files up into their node_modules package directory, and you have a per-package byte attribution. This is why an off-by-one or stale map produces plausible-looking but wrong numbers rather than an error: the tool cannot tell a correct mapping from an incorrect one, it can only follow whatever the map says. It is also why minified output with no map degrades to a single opaque blob — without the mappings field there is nothing to key the bytes against, so every byte belongs to “the chunk” and nothing finer.

Gzip and brotli sizes are estimated differently. The analyzer does not have the server’s exact compression settings, so it runs each module’s source through a compressor at a fixed level and reports the result. That estimate is close but not identical to what the CDN serves, because compression is contextual — a string that also appears in an adjacent module compresses better when the two ship in the same chunk. The practical consequence is that per-module gzip figures do not sum to the chunk’s gzip size; the chunk compresses better than its parts. Trust the whole-chunk gzip number for budgeting and treat per-module gzip as a relative ranking, not an additive total.

Duplication is the subtler cost: two versions of the same package (or one package resolved through two paths) ship twice, and the analyzer shows the same module name at two sizes. This happens when a transitive dependency pins an incompatible range, defeating deduplication. The fix is a version override or an alias so both consumers resolve one copy — the same single-instance concern that governs sharing a singleton across module-federation remotes.

Duplication has two distinct shapes, and conflating them wastes time. The first is genuine version conflict: package A depends on lodash@^3 and package B on lodash@^4, so the resolver installs both and both end up in the graph. No amount of dedupe fixes this, because the two versions are not interchangeable; you either upgrade A to accept lodash@4 or accept the cost. The second is a single version resolved through two paths — the same lodash@4.17.21 installed once at the workspace root and once nested under a hoisting-averse dependency. That one npm dedupe or a resolutions pin genuinely collapses, because the bytes are byte-identical. Read the tree before you reach for a fix: npm ls lodash prints the version at each path, and only when the versions differ do you have a real conflict rather than a hoisting accident.

Raw versus gzip size and how duplication shows up Raw parsed size drives parse and compile time while gzip size drives transfer time, so both belong in a budget; duplication appears as the same package name at two sizes because two incompatible versions resolved separately and both shipped. two size metrics, one duplication trap raw (parsed)parse + compile timeCPU on the device gzip / brotlitransfer timebytes on the wire duplicationsame pkg, two versionsboth shipped
Figure: budget raw and gzip together; treat a package appearing twice as a bug, not a size line.

Configuration & CLI reference

The visualizer registers as a Rollup output plugin, which matters for placement: it runs in the generateBundle hook after Rollup has finished chunking and tree-shaking, so it sees the final module-to-chunk assignment rather than the pre-optimization graph. Put it last in the plugins array so any plugin that rewrites or injects modules has already run before the visualizer takes its snapshot. The template option chooses the layout — treemap for spotting the single largest rectangle at a glance, sunburst for reading the dependency nesting, network for seeing which modules pull in which. In practice treemap is what you reach for daily; the other two are for the occasional “why is this here” investigation.

// vite.config.ts — Vite 5.4.x with the visualizer plugin
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  build: { sourcemap: true },   // required for accurate attribution
  plugins: [
    visualizer({
      filename: 'dist/stats.html',
      template: 'treemap',      // treemap | sunburst | network
      gzipSize: true,            // show gzip alongside raw
      brotliSize: true,
    }),
  ],
});
# source-map-explorer — attribute a built chunk to its source modules.
npx source-map-explorer 'dist/assets/*.js'

# Find duplicate packages in the dependency tree.
npm ls react            # shows every path react resolves through
npm dedupe --dry-run    # reports what could be deduplicated

The two analyzers answer different questions and you will use both. rollup-plugin-visualizer runs inside the build and knows the chunk graph, so it can tell you which chunk a module landed in and how tree-shaking split a package across chunks — information that only exists during bundling. source-map-explorer runs afterward on emitted files, so it works on any build that produces maps regardless of bundler, and because it takes a glob of files and prints machine-readable output it is the one you script. A useful habit is to point source-map-explorer at a single named chunk when investigating a regression rather than the whole dist/assets glob, because narrowing to one file removes the noise of every other chunk and makes the offending module obvious:

# source-map-explorer v2.5.x — attribute one chunk and dump JSON for a script.
npx source-map-explorer dist/assets/index-*.js --json > /tmp/attribution.json

# Pipe the JSON to jq to list the ten heaviest source files by byte count.
npx source-map-explorer dist/assets/index-*.js --json \
  | jq -r '.results[0].files | to_entries | sort_by(-.value.size)[:10][] | "\(.value.size)\t\(.key)"'

On npm ls, read the output as a resolution map, not a duplicate report: it prints every path a package resolves through and the version at each, so a single version listed under three parents is fine (it dedupes to one copy) while two different versions is the signal to act. npm dedupe --dry-run only reports collapses that are safe under the current version ranges; it will never resolve a genuine version conflict, so a small dry-run diff next to a duplicate in the treemap confirms the duplicate is a real conflict needing an override.

Two analyzers with different strengths rollup-plugin-visualizer renders an interactive treemap of the Rollup graph during the build, while source-map-explorer runs after the build on the emitted files and their maps; the visualizer is best for exploring, source-map-explorer for scripting a check. visualizer (build-time)interactive treemapexplore visually source-map-explorerpost-build on filesscriptable in CI explore with one, gate with the other
Figure: use the treemap to explore interactively and source-map-explorer to script a repeatable check.

Step-by-step workflow

Five steps from source-mapped build to enforced budget Build with source maps, open the treemap to find the largest modules and duplicates, confirm duplicates with npm ls and resolve them, set a per-chunk budget with size-limit, then wire that budget into CI so regressions fail the build. 1 build+mapsaccurate input 2 treemaplargest + dupes 3 resolve dupesoverride/alias 4 set budgetper chunk 5 wire CIfail on regress
Figure: step 3 is the free win — removing a duplicate shrinks the bundle with no feature change.
  1. Build with source maps so every analyzer has accurate input. Run the production build (vite build), not the dev server, and confirm dist/assets contains a .map for each .js before you analyze anything. If the maps are missing the rest of the workflow silently degrades to chunk totals, so this step is a gate, not a formality.
  2. Open the treemap (dist/stats.html) and find the largest modules and any package appearing twice. Start with the biggest rectangle and ask whether it belongs on the critical path at all — a charting library, a date formatter, an icon set, and a markdown renderer are the usual suspects, and any of them is often needed on one route rather than the entry. Note every package name you see more than once; those are your free wins before you touch a single feature.
  3. Confirm duplicates with npm ls <pkg> and resolve them with an override or alias. Verify the versions actually differ before overriding — if they match, npm dedupe collapses them without a pin; if they differ, choose the version that both consumers can accept and pin it, then rebuild and confirm the second copy is gone from the treemap. Never pin a version the transitive consumer cannot run; a duplicate is cheaper than a runtime crash.
  4. Set a budget per chunk with size-limit, using gzip size for transfer-sensitive entry chunks. Set the limit a little above where you actually landed after steps 2 and 3 — tight enough that the next real regression trips it, loose enough that ordinary week-to-week noise does not. A budget that fails on every unrelated change gets disabled within a sprint.
  5. Wire the budget into CI so a regression fails the build rather than shipping. The check must run on the same production build the deploy uses and glob the same hashed filenames, because a budget that measures a stale or wrong file is worse than no budget — it is a green check that asserts nothing.

CI integration

A budget that only lives on your laptop protects nothing. The point of the exercise is to move the measurement to the one place every change must pass through, so a size regression is caught by the same gate that catches a failing test. size-limit is built for this: it reads a config of entries and limits, builds or globs the emitted files, computes the metric you asked for, and exits non-zero when any entry is over. Keep the config in the repo next to package.json so the limits are versioned alongside the code they constrain — changing a limit becomes a reviewable diff with a reason attached, not a silent edit to a CI dashboard.

// .size-limit.js — size-limit 11.1.x, gzip budgets per emitted chunk.
module.exports = [
  {
    name: 'entry (first paint)',
    path: 'dist/assets/index-*.js',   // hashed name; glob must stay in sync with output
    limit: '160 KB',                   // gzip by default
  },
  {
    name: 'vendor',
    path: 'dist/assets/vendor-*.js',
    limit: '90 KB',
  },
];
# .github/workflows/size.yml — GitHub Actions, fails the build on a regression.
name: size
on: [pull_request]
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run build        # produce dist/ with the same config as deploy
      - run: npx size-limit       # non-zero exit fails the job

Two operational details make or break this. First, the CI build must be the production build, invoked exactly as the deploy invokes it, or the budget measures an artifact that never ships. Second, run the check on pull requests, not only on the default branch, so the number is visible while the change is still reviewable and the author can attribute the regression to their own diff rather than discovering it a week later in a merged history. If your size-limit output posts to the PR as a comment, the reviewer sees “+18 KB gzip” next to the code that caused it, which is the moment attribution is cheapest and the fix is easiest.

Debugging & failure modes

Four bundle-analysis failure modes An empty treemap means source maps are off; a package appearing twice means an incompatible transitive version pin needs an override; a fine gzip size with slow interactivity means high raw parse size needs budgeting and lazy loading; and a budget that never fails means it points at the wrong file or metric. empty treemap→ enable source maps package appears twice→ overrides / resolutions small gzip, slow page→ budget raw + lazy-load budget never fails→ point at the emitted chunk
Figure: the "budget never fails" case is the dangerous one — a green check that measures nothing.

The treemap is empty or wrong

Symptom: the visualizer shows one opaque block, or a handful of enormous rectangles with names like chunk-XYZ and no source files underneath. Root cause: source maps are off or are not reaching the plugin, so it has no mappings to key bytes against and falls back to reporting whole chunks. This is the single most common analysis failure and it produces a plausible-looking output rather than an error, which is what makes it dangerous. Fix by setting build.sourcemap: true in the Vite config (or the equivalent in your bundler) and rebuilding in production mode; a dev build can emit maps that the production output does not, so verify against the artifact you actually analyze. Confirm the fix by opening the treemap and checking that leaf rectangles carry real file paths from src/ and node_modules/, not opaque chunk hashes.

A dependency appears twice

Symptom: two entries for the same package name, usually at two different versions, occupying two separate rectangles in the treemap. Root cause: a transitive dependency pins a range incompatible with your direct dependency, so the resolver installs both and both land in the graph — deduplication cannot merge versions that are not interchangeable. Confirm it is a real conflict, not a hoisting artifact, by running npm ls <pkg> and reading the version at each path; identical versions dedupe on their own, differing versions need intervention. Fix with an overrides (npm/yarn) or resolutions (yarn classic/pnpm) entry that forces one version, choosing the version both consumers can actually run. Confirm by rebuilding and checking the second rectangle is gone; if the app then throws at runtime, you pinned a version a consumer could not accept and should revert to tolerating the duplicate.

// package.json — npm 9+/pnpm: collapse two lodash copies onto one version.
{
  "overrides": {
    "lodash": "4.17.21"   // forces every consumer onto this exact copy
  }
}

gzip size looks fine but the page is slow

Symptom: the transfer size in DevTools looks small, the page still takes seconds to become interactive, and the main thread is pinned during load. Root cause: raw (parsed) size, not transfer size, is the bottleneck — a library can compress to a fraction of its source yet still cost the JavaScript engine tens of milliseconds per hundred kilobytes to parse and compile, and that work happens on the main thread before the app responds. Gzip measures the network; raw measures the CPU, and on a mid-range phone the CPU is usually the constraint. Fix by budgeting raw size in addition to gzip, and by moving the heavy module off the entry path with a dynamic import() so it parses only on the route that needs it. Confirm with a Performance trace: the long “Compile Script” or “Evaluate Script” block should shrink or move out of the initial load once the module is deferred.

The budget never fails

Symptom: CI stays green through a change you know added weight, and the reported number never moves. Root cause: the budget is measuring the wrong thing — most often a hashed-filename glob (index-a1b2c3.js) that stopped matching after the hash changed, so size-limit finds zero files and trivially passes, or a config that asserts raw bytes while the regression landed in gzip. A budget that measures nothing is worse than no budget, because the green check actively tells the team the size is safe. Fix by globbing the stable part of the hashed name (index-*.js) and confirming size-limit reports the number of files it matched; if it says zero, the glob is wrong. Confirm the gate actually bites by temporarily lowering a limit below the current size and checking the job goes red, then restore it.

Performance impact & measurement

Two highest-leverage size wins The two biggest wins are removing a duplicate copy of a package, which shrinks the bundle instantly with no feature change, and lazy-loading a route-specific heavy dependency so it leaves the entry chunk entirely. where the bytes actually come off remove duplicate copyinstant, zero feature changeoverride the version lazy-load heavy depleaves the entry chunkroute-specific import()
Figure: dedup and lazy-load beat micro-optimizing any single module — chase those first.

The metric that correlates with user experience is gzip (or brotli) size of the entry chunk, because that is what blocks first paint, plus raw size of anything on the critical path, because that is what blocks interactivity while the main thread parses. Capture both from the visualizer’s gzipSize/brotliSize output and track them over time — a single dependency can add 40 KB gzip and 150 KB raw without changing a visible feature. The highest-leverage wins are almost always removing a duplicate copy of a package (instant, zero feature change) and lazy-loading a route-specific heavy dependency so it leaves the entry chunk entirely. Measure after each change and record the number in the same units your budget uses, so the CI gate and your local measurement never disagree.

Rank optimizations by cost-to-benefit, not by which module is largest. Removing a duplicate is free — the feature is unchanged and the bytes are pure waste — so it always goes first. Lazy-loading a route-specific dependency is nearly free, costing one import() and a loading state, and it removes the module from the entry chunk entirely rather than merely shrinking it. Switching to a lighter alternative library (a 3 KB date helper for a 70 KB one) is a real code change with a real testing cost, so it comes third. Hand-optimizing your own modules comes last and rarely pays: application code is usually a small fraction of a bundle dominated by dependencies, and the hours spent shaving your own kilobytes buy less than one import() on a chart library. The analyzer’s job is to point you at the top of that list; the discipline is to work it in order and stop when the next win costs more than it returns.

When not to reach for this

Bundle budgets are a tax on every build, and the tax is only worth paying where bytes reach a user over a network on the critical path. An internal admin tool behind a login, used by a dozen employees on office laptops, does not need a 160 KB gzip gate — the users will never notice 400 KB, and the CI check will only generate friction on unrelated changes. The same is true of a fully server-rendered page whose interactivity is trivial, or a desktop-only Electron app where the code ships inside the binary rather than over the wire. Reserve the machinery for public-facing web surfaces where first paint and time-to-interactive are product metrics. Even there, do not budget every chunk; budget the entry and the shared vendor chunk, where regressions actually hurt, and leave the deep lazy-loaded routes ungated so the check stays meaningful and does not cry wolf.

Comparison with webpack-bundle-analyzer

If you come from webpack, webpack-bundle-analyzer is the tool you already know, and the mental model transfers almost intact: both render an interactive treemap of the module graph with raw and gzip sizes, and both are exploration tools rather than gates. The difference is where the data comes from. webpack-bundle-analyzer reads webpack’s own stats object, which describes the internal module graph directly, so it needs no source maps and can show pre-tree-shake module boundaries. rollup-plugin-visualizer and source-map-explorer reconstruct attribution from the emitted output and its maps, which is why source maps are non-negotiable for the Vite/Rollup path but irrelevant for the webpack one. For enforcement the comparison is simpler: neither analyzer fails a build, so on any bundler you still reach for size-limit (or bundlesize) to draw the line. Pick the analyzer that matches your bundler and treat the budget tool as bundler-agnostic — the enforcement layer is the part that has to be identical across your projects, not the exploration layer.

Compatibility matrix

Four tools mapped to the job each does best rollup-plugin-visualizer is best for interactive exploration, source-map-explorer for scriptable attribution, size-limit for CI budget enforcement, and npm or pnpm ls with dedupe for finding duplicate packages. pick the tool by the job, not the other way round visualizerexplore source-map-explorerattribute size-limitenforce budget ls + dedupefind duplicates
Figure: the four tools do not overlap much — explore, attribute, enforce, and dedup are distinct jobs.
Tool Works with Metric Best for
rollup-plugin-visualizer Vite / Rollup builds raw + gzip + brotli interactive exploration
source-map-explorer any build with maps raw (via maps) scriptable attribution
size-limit any dist glob gzip / brotli / time CI budget enforcement
npm/pnpm ls + dedupe any workspace dependency paths finding duplicates

The four tools barely overlap, which is the point: each does one job well and none replaces another. The visualizer is where you look when you do not yet know what is wrong; source-map-explorer is where you script a repeatable attribution once you do; size-limit is the gate that keeps the answer fixed; and ls/dedupe is the dependency-tree view that neither analyzer gives you. A common mistake is trying to enforce a budget with the visualizer (it has no exit code) or diagnose a duplicate with size-limit (it reports a total, not a tree). Match the tool to the question — explore, attribute, enforce, or dedup — and the workflow stays short.

In-Depth Guides