Teams that ship without bundle visibility watch their initial JS payload drift from a healthy 180 KB gzipped to over 500 KB across two or three quarters of unmonitored feature work, with no single commit large enough to trigger a manual review. Wiring a treemap analyzer into every build and a size-limit gate into every pull request catches 90%+ of these regressions before merge, converting bundle health from a quarterly fire drill into a property that CI enforces automatically, the same way test coverage or lint rules are enforced.

This section is deliberately narrower in scope than the areas of the site that change what ships. Code splitting decides what gets deferred; tree-shaking decides what gets eliminated; this section decides whether either of those actually worked, and whether the next unrelated feature commit quietly undoes the gains. A bundle analyzer answers “what is actually in this file right now,” a performance budget answers “is that still within the limit we agreed on,” and a CI gate answers “should this specific change be allowed to merge.” None of the three is sufficient alone — a treemap without a budget is a report nobody re-reads after the first audit, and a budget without CI enforcement is a number in a config file that nobody is forced to respect.

Baseline performance targets

Metric Target threshold Impact of correct implementation
Initial JS payload (gzipped) < 150 KB Keeps TTI under 3.5 s on 4× CPU throttle
Duplicate-module overhead < 5% of total bundle weight Recovered via dedupe once flagged by an analyzer
CI regression-detection rate > 90% of size regressions Caught pre-merge instead of in production
Budget-check CI runtime < 60 s Keeps analysis inside the normal PR feedback loop
Treemap top-5-module concentration flag above 40% of a chunk Signals a single dependency worth isolating or replacing
Lighthouse CI total-byte-weight assertion 150–180 KB Mirrors the byte budget with real network timing
Source-map attribution coverage 100% of production chunks Enables byte-accurate bloat attribution after deploy
Time from regression introduced to CI failure 1 PR cycle Prevents multi-sprint size creep

These thresholds assume a mid-scale React or Vue 3 SPA in the 200–800 KB uncompressed range, the same baseline used across the tree-shaking and route-based code splitting pillars, so budgets set here stay consistent with the size targets those techniques are optimising toward. Treat the table as a starting point rather than a fixed prescription: a marketing site with a single route can hold a tighter initial-chunk budget than a dashboard application with dozens of authenticated routes behind route-level code splitting, but the ratios — duplicate-module overhead under 5%, regression detection above 90%, a CI runtime under a minute — hold regardless of the application’s absolute size, because they describe the health of the process, not just the payload.

How the analyze → budget → enforce loop fits together

Bundle analysis and performance budgets are not a one-time audit; they are a closed feedback loop that runs on every commit. A build produces a stats artifact, an analyzer turns that artifact into a human-readable treemap, a budget check compares specific chunk sizes against thresholds, and a CI gate either lets the change through or blocks it. The loop repeats on the next commit, so regressions are caught at the moment they are introduced rather than discovered weeks later in a production performance audit.

Bundle analysis and performance budget enforcement loop A left-to-right flow diagram: Build Output feeds a Bundle Analyzer stage, which feeds a Budget Check stage, which feeds a CI Gate stage. The CI Gate either passes (merge) or fails, in which case a dashed box below shows the regression being blocked. A loop line runs from the CI Gate back down and around to Build Output, labelled next commit, showing the cycle repeats on every pull request. Build Output dist/ + stats.json Bundle Analyzer treemap (webpack- bundle-analyzer / rollup-plugin-visualizer) source-map-explorer Budget Check size-limit / bundlesize per-chunk gzip limit Lighthouse CI byte-weight CI Gate pass → merge fail → block PR Regression blocked merge rejected until fixed next commit — the loop re-runs on every pull request

Each stage in this loop maps to a distinct tool family that has its own dedicated coverage on this site: visualizing bundle composition with analyzers covers the treemap stage, enforcing performance budgets in CI covers the budget-and-gate stages, and attributing bundle bloat with source maps covers the deeper byte-attribution work that treemaps alone can’t do.

Notice that the loop has no natural exit — it isn’t a project that finishes, it’s a control system that runs for the lifetime of the application. That has a practical consequence for how the tooling should be introduced: a one-off analyzer run during a “performance sprint” produces a report that’s accurate for exactly as long as nobody merges another dependency, which is usually days. The value comes from the CI Gate stage specifically, because it’s the only stage that runs unconditionally rather than on request.

Core deep-dive: analyzer and budget configuration, Webpack 5 vs Vite 5+

Generating a stats artifact

Every downstream tool — treemap, budget checker, or CI diff — needs a machine-readable record of what the build produced. Webpack emits this natively; Vite’s Rollup back-end needs the visualizer plugin wired into the build itself so the metafile is produced as part of vite build rather than a separate pass.

# Webpack 5 — emit a stats artifact alongside the normal build
webpack --json=stats.json --profile
// vite.config.js — Vite 5+: rollup-plugin-visualizer writes stats.html during build
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [
    visualizer({
      filename: 'dist/stats.html',
      template: 'treemap',
      gzipSize: true,
      brotliSize: true
    })
  ]
};

Treemap analyzers, side by side

webpack-bundle-analyzer reads the compiler’s own stats and renders a resizable treemap keyed to gzip size; rollup-plugin-visualizer does the same for Rollup/Vite output. Both should run in static (file-output) mode in CI, not the interactive server mode meant for local development.

// webpack.config.js — Webpack 5: static analyzer report on every production build
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  mode: 'production',
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',       // Write an HTML file instead of starting a server
      reportFilename: 'report.html',
      openAnalyzer: false,
      generateStatsFile: true,
      statsFilename: 'stats.json'
    })
  ]
};
// vite.config.js — Vite 5+: gate the visualizer plugin behind an ANALYZE env var
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    process.env.ANALYZE && visualizer({
      filename: 'dist/report.html',
      template: 'treemap',
      gzipSize: true
    })
  ].filter(Boolean)
});

The reading webpack-bundle-analyzer treemaps guide walks through interpreting the box sizes and colour groupings this plugin produces, and setting up rollup-plugin-visualizer in Vite covers the equivalent workflow for a Rollup-based build.

Byte budgets with size-limit, side by side

size-limit is bundler-agnostic — it measures whatever glob of files you point it at — but the glob pattern differs between Webpack’s default output naming and Vite’s assets/ convention.

// package.json — size-limit config for a Webpack 5 output layout
{
  "size-limit": [
    { "path": "dist/main.*.js",   "limit": "150 KB", "gzip": true },
    { "path": "dist/vendor.*.js", "limit": "90 KB",  "gzip": true }
  ],
  "scripts": { "test:size": "size-limit" }
}
// package.json — size-limit config for a Vite 5+ output layout
{
  "size-limit": [
    { "path": "dist/assets/index-*.js",  "limit": "150 KB", "gzip": true },
    { "path": "dist/assets/vendor-*.js", "limit": "90 KB",  "gzip": true }
  ],
  "scripts": { "test:size": "size-limit" }
}

bundlesize offers a lighter alternative with the same intent, useful when a project needs a single top-level check rather than per-chunk granularity:

// package.json — bundlesize as a single top-level gate
{
  "bundlesize": [
    { "path": "./dist/**/*.js", "maxSize": "160 KB", "compression": "gzip" }
  ]
}

Full CI wiring for both — including which check to mark as required in branch protection — is covered in configuring size-limit as a GitHub Action and failing builds on bundle-size regressions.

Lighthouse CI as the timing-side budget

Byte budgets catch payload creep; Lighthouse CI catches the timing consequences, including regressions that don’t come from JS at all (a blocking font, a synchronous third-party script). Run it against the same production build the byte budgets check:

// lighthouserc.json — Webpack 5 / Vite 5+ production build, throttled profile
{
  "ci": {
    "collect": { "numberOfRuns": 3, "settings": { "throttlingMethod": "simulate" } },
    "assert": {
      "assertions": {
        "total-byte-weight":      ["error", { "maxNumericValue": 170000 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1800 }],
        "interactive":            ["error", { "maxNumericValue": 3500 }]
      }
    }
  }
}

Quantified impact by technique

Benchmarks below are measured on a mid-scale e-commerce SPA (Webpack 5.90 / Vite 5.2) that had drifted to a 430 KB uncompressed initial bundle before analysis tooling was introduced:

  • Adopting a treemap analyzer in CI — cuts average time-to-diagnose a size regression from several hours of manual du inspection to under 5 minutes, because the box sizes point directly at the offending module.
  • size-limit gate as a required CI check — catches 90%+ of size regressions at PR time rather than in a post-release audit, and stops the slow, unnoticed creep that turns a 180 KB bundle into 500 KB over two quarters.
  • source-map-explorer byte attribution — resolves ambiguous “which team’s code is this” questions in monorepos in one pass, typically identifying 10–20% of a chunk’s weight as attributable to a single oversized dependency.
  • Duplicate-dependency detection and dedupe — recovers 12–25% of total bundle weight in dependency trees with mismatched semver ranges across sibling packages.
  • Lighthouse CI byte-weight + timing assertions together — protects 200–450 ms of TTI headroom that a byte-only budget can miss, since it also catches non-JS regressions.
  • PR-level bundle-diff bot comments — increases the rate at which engineers self-correct a regression before requesting review, because the size delta is visible in the same place as the code diff.
  • Combined analyzer + budget + Lighthouse CI pipeline — sustains a sub-150 KB gzipped initial payload indefinitely, rather than requiring a periodic manual “bundle cleanup” sprint every few quarters.

Common architectural pitfalls

Most of the failures below share a root shape: the tooling exists and appears to work, but it’s measuring the wrong artifact, gating on the wrong number, or not actually blocking anything. Each is easy to introduce accidentally and easy to miss in review, because the CI log still shows a green checkmark.

Analyzing a development build

# Anti-pattern: dev builds are unminified and include HMR/dev-only code
webpack --mode development --json=stats.json

Development builds skip minification, tree-shaking, and scope hoisting, so a treemap generated from one wildly overstates real production weight and misrepresents which modules are actually expensive after Terser or esbuild compression. Root cause: the analyzer is reading the wrong artifact for the question being asked. Measurable penalty: treemap sizes can be 2–4× production values, leading teams to “optimise” modules that shrink to near nothing after minification while ignoring ones that don’t. Fix: always point analyzers at a mode: 'production' (Webpack) or vite build (not vite dev) output.

Budgeting raw size instead of gzip/brotli size

A budget configured against uncompressed byte count doesn’t track what the browser downloads. Whitespace, comment stripping, and identifier length change raw size in ways gzip mostly normalises away, since repetitive tokens compress well regardless of variable naming. Diagnostic signal: a budget check passes locally but the deployed asset, measured in the Network panel, is well under the raw-size number that was actually gated. Fix: set every size-limit/bundlesize entry with "gzip": true (or "brotli": true if the CDN serves brotli), matching the compression the CDN actually applies.

Soft-fail budget checks

# Anti-pattern: exit code is discarded, so the job always shows green
- name: Check bundle size
  run: npm run test:size || true

A budget check that logs a warning but doesn’t fail the pipeline provides the illusion of enforcement without the substance. Root cause: || true (or an equivalent continue-on-error setting) swallows the non-zero exit code that size-limit returns on a threshold breach. Measurable penalty: regressions accumulate exactly as if no check existed at all, just with more noise in the CI log. Fix: remove the fallback, mark the job as a required status check in branch protection, and treat a budget failure the same as a failing test.

Ignoring duplicate module instances

Two versions of the same package resolving into separate chunks is invisible in a byte-count-only view — the total still “adds up” — but shows up unmistakably as two same-named boxes in a treemap or two require() traces for one package in stats.json. Diagnostic signal: npm ls <package> or pnpm why <package> shows multiple resolved versions across the dependency tree. Measurable penalty: 12–25% of bundle weight recoverable purely through deduplication, no code changes required. Fix: add a resolutions (Yarn) or overrides (npm) field pinning the package to one version, or a Vite resolve.dedupe / Webpack resolve.alias entry, the same technique used for vendor chunk isolation to keep shared runtime helpers from splitting into duplicate copies. The finding duplicate dependencies in a bundle guide covers the exact detection commands.

Running analysis only when someone remembers to

Treating bundle analysis as a manual, occasional audit means regressions compound silently between audits. A budget that isn’t enforced on every PR is a budget that exists only in documentation. Fix: the analyzer report and the byte budget should both run unconditionally on every pull request, not on a schedule or a manual trigger — the CI/CD section below covers exactly how to wire that.

Budgeting the whole bundle instead of individual chunks

A single top-level budget (dist/**/*.js under 300 KB total) can pass even when the initial chunk — the one that blocks first paint — quietly balloons, as long as a lazy-loaded route chunk shrank by the same amount elsewhere. Root cause: the budget doesn’t distinguish between bytes downloaded on first load and bytes downloaded on-demand, so it can’t catch a regression in the chunk that actually determines TTI. Diagnostic signal: total bundle size stays flat in CI while Lighthouse’s interactive timing quietly regresses release over release. Fix: budget the initial/entry chunk and the vendor chunk separately from route chunks, mirroring the per-chunk size-limit entries shown above rather than a single blanket glob.

CI/CD and tooling integration

A complete pipeline layer runs three checks on every pull request, in this order, so an early cheap check can short-circuit before a slower one runs:

1. Byte budget (fastest, blocks on regression)

# .github/workflows/bundle-budget.yml — Webpack 5 / Vite 5+
name: bundle-budget
on: pull_request
jobs:
  size-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run build
      - run: npx size-limit

2. Analyzer report artifact (informational, attached to the PR)

# .github/workflows/bundle-report.yml — attaches a treemap report as a build artifact
      - run: ANALYZE=true npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: bundle-report
          path: dist/report.html

3. Lighthouse CI (timing-side budget, runs against the built app)

# .github/workflows/lighthouse.yml — Webpack 5 / Vite 5+ production build
      - run: npm run build
      - run: npx @lhci/cli autorun

Keep the byte-budget job under 60 seconds by caching node_modules (keyed on the lockfile hash) and, for Webpack, the persistent cache directory. Vite’s node_modules/.vite pre-bundle cache should be restored from CI cache too, but never committed — it’s derived state, not source. Pair the analyzer artifact with a bot comment (e.g. a compressed-size-action-style step) that posts the size delta directly on the PR diff; visibility at review time catches regressions that a passing-but-close-to-threshold budget check might not flag as urgent.

Mark the size-limit job specifically as a required status check in the repository’s branch protection settings — an analyzer report and a Lighthouse run are valuable signal, but only the byte-budget job should have the power to block a merge, since it’s the one with an unambiguous pass/fail threshold.

For monorepos with multiple deployable apps sharing a workspace, run the byte-budget job as a matrix across each app’s build output rather than a single aggregate check, so a regression in one app’s initial chunk doesn’t get averaged away against an unrelated app that shrank. This also keeps the per-app size-limit config co-located with that app’s package.json, which matters once resolutions/overrides fields start diverging between apps that depend on different major versions of the same shared library.

Debugging and runtime validation

Reading a treemap without guessing

Open the generated report.html and sort by size, not by directory structure — webpack-bundle-analyzer and rollup-plugin-visualizer both support this. A single box occupying more than 40% of a chunk is worth individual scrutiny: it’s either a genuinely large dependency that should be code-split into its own route-level dynamic import, or a dependency pulled in whole when only a fraction of its exports are used, a symptom barrel-file elimination usually fixes.

Attributing bytes to source lines

Treemaps show module boundaries; they don’t show which specific lines inside a large minified module are actually expensive. source-map-explorer closes that gap by walking the source map and building its own treemap keyed to original file paths:

# Webpack 5 / Vite 5+: requires a production build with source maps enabled
npx source-map-explorer dist/assets/index-*.js --html report.html

This is the right tool when a webpack-bundle-analyzer box says “vendor chunk: 210 KB” but doesn’t say which of the twelve packages inside it accounts for most of that weight. It depends on accurate source maps existing in the first place — see source map generation and debugging workflows for devtool and build.sourcemap settings that keep maps accurate without leaking source to end users, and fixing source map mismatches in Webpack 5 for the specific failure mode where line numbers drift after a minifier pass. The analyzing chunks with source-map-explorer guide covers the full workflow end to end.

Validating budgets hold up at runtime

A passing CI check confirms the byte count; it doesn’t confirm the network behaviour a user actually experiences. After deploy, open Chrome DevTools → Network with a throttled profile (Fast 3G / 4× CPU) and confirm the initial chunk’s transferred size matches what size-limit measured — a mismatch usually means the CDN isn’t applying the compression the budget assumed. Cross-check chunk filenames against deterministic chunk hashing expectations: if a hash changes on a deploy where the budget check reported no size delta for that chunk, something in the build is injecting non-deterministic content (timestamps, absolute paths) that stabilizing chunk hashes resolves, and that instability silently erodes the >92% cache-hit rate long-term caching depends on.

Correlating budget failures with the treemap

When a size-limit job fails, don’t just bump the threshold — re-run the analyzer against that exact build and diff the treemap against the last passing build’s report. In most cases the size delta traces to one of: a new dependency added without review, a duplicate module instance, or a barrel import that pulled in more than intended. Treating the analyzer and the budget check as a pair, rather than the budget check alone, turns “the bundle got bigger” into “this specific module got bigger, for this specific reason.”

Cross-checking analyzer size against executed coverage

A treemap only measures what shipped, not what ran. Pair it with Chrome DevTools → Coverage on a fresh page load: any module that’s large in the treemap but shows low execution percentage in the Coverage panel is a strong candidate for route-level code splitting rather than shipping in the initial chunk at all. This distinction matters because a byte budget alone can’t tell the difference between a large module that’s actually needed immediately and one that’s merely present — Coverage data closes that gap, and it’s the same signal worth checking after any tree-shaking configuration change, to confirm the eliminated code was genuinely unreachable rather than deferred.

FAQ

What is the difference between webpack-bundle-analyzer and source-map-explorer?

webpack-bundle-analyzer draws a treemap from the bundler’s own module graph, so it shows what the bundler believes it packed. source-map-explorer instead parses the emitted source map and attributes every byte in the final minified file back to its original source line, which catches bloat that the module graph view hides, such as inlined polyfills or duplicated helper code introduced during minification.

How do I stop a pull request from merging when it introduces a bundle-size regression?

Add a size-limit or bundlesize check as a required GitHub status check, not just an informational comment. The check must exit non-zero when a chunk exceeds its configured gzip threshold, and branch protection rules must mark that check as required before merge is allowed.

Why does my bundle contain two copies of the same dependency?

Duplicate module instances usually come from semver-range mismatches across a dependency tree, where two packages depend on incompatible versions of the same library and the package manager installs both instead of deduplicating. A treemap analyzer will show two same-named modules at different paths; resolving it requires a resolutions/overrides field or a resolve.dedupe entry pointing both consumers at one physical copy.

Should performance budgets be set on raw or gzipped bundle size?

Set budgets on gzipped (or brotli) size, because that is what the browser actually downloads. Raw byte counts vary with formatting and comment stripping in ways that don’t reflect network cost, and a budget tuned to raw size will pass or fail inconsistently with real-world load time.