Enforcing Performance Budgets in CI

A dependency bump that looks unrelated to performance — a patch release of a date library, a new icon added to a shared component — routinely adds 20-50 KB to a production bundle without a single reviewer noticing in the diff. Multiply that across a few dozen merged PRs a month and a codebase that shipped 140 KB gzipped in January is shipping 210 KB by summer, with Time to Interactive creeping upward by 300-500 ms on a throttled mid-tier device and no single commit that a git bisect would flag as “the regression.” Performance budgets enforced in CI close that gap: instead of relying on a human to notice a bundle got heavier, a build step measures every PR’s output against a byte threshold and fails the check before merge.

This page covers how to define those thresholds per chunk type, how to wire size-limit, bundlesize, and Lighthouse CI assertions into a GitHub Actions pipeline, and the Webpack 5 and Vite 5+ specifics for each. It assumes you already have a working production build; if you need to see where the bytes are actually coming from before you decide what to budget, start with visualizing bundle composition with analyzers.

Where a budget gate fits in the release pipeline

A performance budget is not a single script — it is a pipeline stage sitting between “build succeeded” and “merge allowed.” It depends on the build producing deterministic, attributable output, which is why chunk boundaries and cache-busting strategy matter before budget enforcement is even worth setting up: a pipeline whose vendor chunk hash changes on every commit for reasons unrelated to code (a problem covered in stabilizing chunk hashes to maximize cache hits) makes it hard to compare “this PR’s vendor chunk” against “the base branch’s vendor chunk” with any confidence.

The broader discipline this page belongs to is bundle analysis and performance budgets — measuring what ships and holding it to a threshold. Budget enforcement is the automation layer on top of that measurement: a treemap or source-map report tells a human where the bytes are, while a CI gate tells the merge queue whether the bytes are acceptable, without anyone having to look.

Performance budget gate pipeline A left-to-right flow diagram. A PR Opened box leads to a CI Build box (webpack build or vite build, emitting stats.json), which leads to a Measure box (size-limit or bundlesize reads each chunk, gzip applied), which leads to a Compare to Baseline box (diff against the base branch's stored stats). The comparison splits into two outcome boxes: Pass, where merge is allowed, and Fail, where the merge is blocked and a PR comment is posted with the byte delta. PR Opened new commit pushed CI Build webpack / vite build emits stats.json production mode Measure size-limit / bundlesize gzip each chunk path per-chunk byte totals Compare to Baseline diff vs base branch delta % per chunk Pass merge allowed status check green Fail merge blocked exit 1 + PR comment within budget over budget

Defining byte budgets per chunk type

Not every chunk should be held to the same threshold, because not every chunk blocks the same thing. The initial entry chunk delays first render for every visitor on every page; a lazy route chunk only delays the transition to that one route. A single flat budget either starves route chunks of headroom they don’t need to be denied, or lets the entry chunk drift because the average across all chunks still looks fine.

Chunk type Budget (gzip) Rationale
Initial/entry chunk < 150 KB Blocks first paint for every visitor; matches the site-wide well-optimized target
Vendor chunk < 80 KB Isolated per vendor chunk isolation and third-party management; changes rarely, so a tight cap is affordable
Per-route lazy chunk < 50 KB Only penalizes users who navigate to that route
CSS bundle (if extracted) < 40 KB Render-blocking but typically much smaller than JS
Total initial payload (JS + CSS, gzip) < 180 KB Ceiling covering entry JS plus critical CSS together

These numbers derive from the same baseline used across this site: a naive production bundle without splitting or tree-shaking runs 400-600 KB uncompressed, while a well-optimized initial payload lands under 150 KB gzipped. If your team hasn’t yet applied route-based code splitting or advanced tree-shaking, set the budget gate at your current measured size first — a gate that fails on day one teaches the team to ignore it. Ratchet the threshold down 10-15% at a time as each optimization lands.

size-limit, bundlesize, and Lighthouse CI: choosing the right tool

size-limit reads a list of file globs, applies gzip or brotli, and compares each result against a per-entry limit. It is the most precise of the three for chunk-level budgets because it lets you target exact output paths (dist/assets/index-*.js) and choose the compression algorithm explicitly. It has no opinion about runtime metrics — it only measures bytes on disk.

bundlesize predates size-limit and does a similar job with a simpler configuration surface (a flat array in package.json with path and maxSize). It’s a reasonable choice for small projects that want a single-file budget without size-limit’s plugin system, but it lacks size-limit’s per-chunk brotli support and its GitHub status-check integration is less actively maintained.

Lighthouse CI operates at a different altitude: instead of measuring specific files, it loads the built page in headless Chrome and asserts on runtime metrics — total-byte-weight, first-contentful-paint, interactive — under a throttling profile you control. It catches regressions that byte-counting tools miss, such as a new third-party script tag injected outside the bundler’s graph entirely, but its assertions are noisier run-to-run than a deterministic byte count.

The two categories are complementary, not competing: use size-limit or bundlesize for hard, deterministic per-chunk gates, and Lighthouse CI for a broader page-weight and runtime-metric ceiling. Most production pipelines run both.

Webpack 5 and Vite 5+ configuration, side by side

Webpack 5

Webpack 5 has a built-in performance block that emits warnings or errors directly from the compiler, plus size-limit’s own webpack-file preset for finer per-chunk control.

// webpack.config.js — Webpack 5
module.exports = {
  mode: 'production',
  performance: {
    maxAssetSize: 153600,       // 150 KB — fails the compiler step itself
    maxEntrypointSize: 184320,  // 180 KB combined initial JS + CSS
    hints: 'error'               // Turn the built-in warning into a hard build failure
  },
  output: {
    filename: 'assets/[name].[contenthash:8].js',
    chunkFilename: 'assets/[name].[contenthash:8].chunk.js'
  }
};
// package.json — size-limit config for Webpack 5 output
{
  "size-limit": [
    { "name": "entry",  "path": "dist/assets/main.*.js",   "limit": "150 KB", "gzip": true },
    { "name": "vendor", "path": "dist/assets/vendor.*.js", "limit": "80 KB",  "gzip": true }
  ],
  "scripts": { "budget": "size-limit" }
}

maxAssetSize and maxEntrypointSize operate on raw (uncompressed) bytes measured by the compiler, so they need to be set roughly 3-4x the gzip target to represent the same real threshold — a common source of confusion when a team copies a gzip number straight into performance.maxAssetSize and wonders why the compiler never trips. Keep size-limit’s gzip-based check as the source of truth and treat performance.hints as a coarse backstop during local webpack build runs.

Vite 5+

Vite doesn’t expose an equivalent built-in budget hook — Rollup’s build.rollupOptions controls chunking but not enforcement — so the gate lives entirely in size-limit, pointed at the emitted dist/assets/ output.

// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js'
      }
    },
    reportCompressedSize: true // Print gzip size in the build summary for a quick sanity check
  }
});
// package.json — size-limit config for Vite 5+ output
{
  "size-limit": [
    { "name": "entry",  "path": "dist/assets/index-*.js",  "limit": "150 KB", "gzip": true },
    { "name": "vendor", "path": "dist/assets/vendor-*.js", "limit": "80 KB",  "gzip": true },
    { "name": "route-chunks", "path": "dist/assets/!(index|vendor)-*.js", "limit": "50 KB", "gzip": true }
  ]
}

Because Vite’s reportCompressedSize only prints a summary rather than failing the build, size-limit (or an equivalent Node gate script, covered on the sibling page about failing builds on bundle-size regressions) is the only enforcement mechanism for Vite projects — there is no compiler-level performance.hints analog to fall back on.

Wiring the gate into GitHub Actions

Both configurations above run the same way in CI: build, then run the budget tool as a required status check.

# .github/workflows/performance-budget.yml — Webpack 5 or Vite 5+
name: Performance Budget
on: [pull_request]
jobs:
  budget:
    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

Mark the budget job as a required check in the repository’s branch protection rules so a red budget run blocks merge the same way a failing test suite does. The exact per-PR annotation flow — posting the size delta as a PR comment instead of only a pass/fail check — is covered step by step in configuring size-limit as a GitHub Action.

For the Lighthouse CI half of the pipeline, add a parallel job that asserts on runtime metrics rather than static bytes:

# .github/workflows/lighthouse-budget.yml — Webpack 5 or Vite 5+
name: Lighthouse Budget
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci && npm run build
      - run: npx @lhci/cli autorun --config=lighthouserc.json
// lighthouserc.json — asserts on runtime page-weight, not just static chunk bytes
{
  "ci": {
    "collect": { "numberOfRuns": 3, "staticDistDir": "./dist" },
    "assert": {
      "assertions": {
        "total-byte-weight": ["error", { "maxNumericValue": 184320 }],
        "interactive": ["error", { "maxNumericValue": 3800 }]
      }
    }
  }
}

Framework integration: budgets that respect lazy boundaries

A flat “total JS under 150 KB” budget breaks the moment an application adopts dynamic import patterns for on-demand loading — the whole point of React.lazy and Suspense boundaries, or Vue 3’s defineAsyncComponent, is that most of the application’s JS is not part of the initial payload. Budget tooling needs to be told which glob represents “initial” and which represents “everything else,” or it will either falsely fail on a healthy app (summing every route chunk into one number) or falsely pass on an unhealthy one (averaging a bloated entry chunk against dozens of tiny route chunks).

In a React app using React.lazy, the entry bundle should contain the router shell and above-the-fold components only; every route component behind a lazy(() => import('./Route')) call lands in its own chunk and gets the looser per-route budget from the table above, not the entry budget. The same split applies to Vue 3’s defineAsyncComponent — the async wrapper’s chunk is measured against the route-chunk threshold, while the root app shell is measured against the entry threshold. Naming chunks predictably (route-*, vendor-*, index-*) is what makes a size-limit glob pattern able to draw this distinction automatically instead of requiring a hand-maintained allowlist per PR.

Quantified impact of CI-enforced budgets

  • Teams that add a required budget check typically catch 80-90% of size regressions before merge, versus discovering them in a post-release bundle audit weeks later.
  • A 150 KB gzip entry-chunk ceiling, held consistently, keeps Time to Interactive within 200-450 ms of the tree-shaking baseline on a 4x CPU throttle / Moto G Power profile — the same device class this site’s other benchmarks use.
  • Per-chunk budgets (rather than one flat total) reduce false-negative merges — PRs that shrink one chunk while silently growing another — by giving each chunk type its own gate instead of a single average.
  • Adding the vendor-chunk-specific 80 KB budget on top of an entry-chunk budget catches roughly a third of regressions that a single combined threshold misses, because vendor bloat and app-code bloat have different root causes and different fixes.
  • Pairing a static byte gate with a Lighthouse CI runtime assertion catches regressions from sources outside the bundler’s graph — injected third-party tags, web font payloads — that a size-limit glob targeting only dist/assets/*.js cannot see at all.

Common pitfalls

Budgeting raw bytes instead of gzip. Root cause: copying a webpack performance.maxAssetSize number directly from a size-limit gzip config, or vice versa. Diagnostic signal: the gate passes locally but the deployed bundle “feels” much larger than the configured limit suggests. Fix: standardize on gzip (or brotli, if that’s what your CDN serves) everywhere the number is quoted, and note the compression algorithm next to every threshold in code comments.

No baseline comparison, only an absolute ceiling. Root cause: the team sets one flat “under 150 KB” gate and stops there. Diagnostic signal: a bundle can creep from 90 KB to 148 KB over twenty PRs, each individually a 3 KB increase, without ever tripping the gate — then a twenty-first PR that adds 3 KB more suddenly fails, and the author who gets blamed didn’t cause most of the growth. Fix: track a percentage delta against the base branch in addition to the absolute ceiling, covered in depth on failing builds on bundle-size regressions.

Non-deterministic chunk hashes breaking the file-glob match. Root cause: a content hash that changes for reasons unrelated to code, such as unstable module ID ordering. Diagnostic signal: size-limit’s glob resolves to zero files, or resolves to a stale chunk from a previous build, because the hash pattern moved. Fix: apply deterministic chunk hashing as covered in deterministic chunk hashing for long-term caching before layering a budget gate on top — a budget check is only as reliable as its ability to find the right file.

Excluding source maps from the size calculation but not from the deployed artifact. Root cause: size-limit and bundlesize both ignore .map files by default, which is correct for gzip-over-the-wire measurement, but teams sometimes assume this means source maps have no build-time cost. Diagnostic signal: CI build time balloons even though the reported bundle size stays flat. Fix: measure build time and artifact count separately from byte budgets; they are different problems with different gates.

Verification workflow

  1. Run the production build locally and record the baseline: npm run build && npx size-limit --json > baseline.json.
  2. Introduce a deliberate regression (add a throwaway import of a known-heavy package) and re-run npx size-limit to confirm the gate actually fails — a budget check that has never been observed to fail is unverified.
  3. Open the browser DevTools Network panel against the built dist/ output (npx serve dist) and confirm the reported gzip size in the Size column matches the size-limit output within a few percent.
  4. Push the change as a PR and confirm the GitHub Actions status check appears, goes red, and blocks the merge button — branch protection rules that reference a check name that doesn’t yet exist silently pass PRs instead of blocking them.
  5. Revert the deliberate regression, push again, and confirm the same check goes green, closing the loop from local measurement to CI enforcement to merge decision.

FAQ

Should performance budgets be enforced on raw size or gzip size?

Enforce on the compressed size that actually crosses the network — gzip at minimum, brotli if your CDN serves it in production. Raw byte counts are useful for parse-time reasoning, but the canonical 150 KB initial / 80 KB vendor thresholds are gzip figures, and enforcing on raw size produces budgets that are 3-4x too loose.

Why does Lighthouse CI report a different total-byte-weight than size-limit?

size-limit measures only the JS chunks you point it at, after the compression you configure. Lighthouse CI’s total-byte-weight audit measures every network transfer for a real page load, including HTML, CSS, fonts, and images, over whatever throttling profile you set. The two numbers answer different questions and should both be tracked, not reconciled into one.

How strict should the CI budget be compared to the target threshold?

Set the hard CI budget slightly above the target — for example, gate at 155 KB gzip when the target is 150 KB — to absorb minifier and CI-runner measurement noise. Track the target as a separate dashboard number and treat any PR that pushes a chunk past 90% of budget as a signal to open a tree-shaking or splitting investigation before the hard gate actually trips.

Can a performance budget gate block a PR that has nothing to do with bundling?

Yes, and this is usually correct behavior. A dependency bump inside an unrelated PR, a new transitive import pulled in by a patch release, or a shared component picking up a heavier icon library all inflate the bundle without the author touching build config. The gate exists precisely to catch these incidental regressions before they compound.