Defining and Enforcing Performance Budgets
AI generated
60fps
ms
Performance · Budgets · CI/CD · Bundle Size
Defining and Enforcing Performance Budgets
From baseline measurement to the CI gate

A performance budget sets clear upper limits for load time, bundle size, and request count before a store becomes slow, instead of reacting only after the fact. Deriving budgets from real baseline measurements and checking them automatically in the build prevents individual deployments or one extra script from quietly degrading performance without anyone noticing.

18 min. read Lighthouse CI · webpack · size-limit Bundle Size · LCP · TTI

1. What a performance budget actually is

A performance budget is a binding upper limit on a measurable technical value, not a vague goal like "the store should be fast". There are three categories: size budgets cap the file size of JavaScript, CSS, and images in kilobytes, timing budgets set upper limits for metrics such as LCP or TTI in milliseconds, and quantity budgets limit the number of requests, fonts, and third-party scripts per page.

The crucial difference from a plain recommendation: a budget is checked by machine and, ideally, blocks the build as soon as it's exceeded. Without that enforcement, a performance budget stays a good intention that gets quietly ignored the next time a deadline looms. Budgets turn performance into a non-negotiable quality requirement, exactly like a failing unit test in the same build.

2. Measuring a baseline instead of guessing numbers

A performance budget that isn't grounded in a real baseline measurement is pure guesswork. The right starting point is a multi-day measurement series with Lighthouse CI or WebPageTest across the most important page types: homepage, category, product detail, and checkout. The median and 75th percentile of those measurements produce a realistic picture of what the store actually delivers today, instead of an aspirational number copied from a blog post.

The budget itself is then set slightly above the current best value, not at the theoretical optimum. A store with a 2.8-second LCP first gets a budget of 2.9 seconds, not 1.5 seconds. That keeps the build green while targeted optimization work continues, and the budget is lowered incrementally with every successful improvement instead of being unreachable from day one.


#!/usr/bin/env bash
# capture-baseline.sh - measure current performance to derive realistic budgets
set -euo pipefail

readonly URLS=(
  "https://shop.example.com/"
  "https://shop.example.com/damen/kleider.html"
  "https://shop.example.com/produkt-slug.html"
)
readonly RUNS=5
readonly OUT_DIR="baseline/$(date +%Y%m%d)"
mkdir -p "$OUT_DIR"

for url in "${URLS[@]}"; do
  slug=$(echo "$url" | sed 's#[^a-zA-Z0-9]#_#g')
  echo "[INFO] Measuring $url ($RUNS runs)"
  npx lighthouse "$url" \
    --preset=desktop \
    --output=json \
    --output-path="$OUT_DIR/${slug}.json" \
    --chrome-flags="--headless" \
    --throttling-method=simulate \
    --quiet
done

# Aggregate median + p75 per metric from the JSON reports
node scripts/aggregate-baseline.js "$OUT_DIR" > "$OUT_DIR/baseline-summary.json"
echo "[OK] Baseline written to $OUT_DIR/baseline-summary.json"

3. Defining size budgets: JS, CSS, and images

Size budgets cap the compressed file size per resource type, usually split into JavaScript, CSS, and images. What matters is which compression the measurement uses: a budget based on uncompressed file size is irrelevant, since the browser downloads the gzip- or brotli-compressed version over the network. A realistic budget for a Hyvä store bundle is often 150 to 200 KB of compressed JavaScript for the critical entry point.

It's also worth distinguishing between an entry-point budget and a total budget: the entry-point budget caps what must load for the first render, the total budget caps everything a page loads over the course of an interaction. For images, a per-image budget is recommended, roughly 200 KB for a hero image, rather than one total budget across all images on a page, since individual oversized files otherwise disappear into the average.

4. Defining timing budgets: LCP, TTI, and friends

Timing budgets set upper limits for user-perceived metrics such as LCP, TTI, and Total Blocking Time. Unlike size budgets, timing values can't be read directly from the code, only determined through a simulated or real measurement. To keep values reproducible in CI, the measurement must run under fixed network and CPU throttling, for example the Slow 4G profile with 4x CPU slowdown, which is what Lighthouse simulates by default.

A timing budget should follow the known Core Web Vitals thresholds as a reference point, but always be adapted to your own baseline. A value of 2.5 seconds for LCP is sensible as a general target, but a store currently sitting at 4 seconds needs intermediate milestones, otherwise every build fails immediately and the budget gets ignored or removed entirely.

5. Quantity budgets: requests, fonts, third-party scripts

Quantity budgets don't cap size but the number of individual resources, because every additional request carries its own latency cost from DNS lookup, TLS handshake, and connection setup. A typical budget caps the total number of requests on a product page at roughly 60 to 80, limits loaded web fonts to at most two font families with two weights each, and caps the number of active third-party scripts at a fixed number, often no more than five.

Third-party scripts in particular are the most common cause of creeping regression, because each one looks small on its own but they add up: a tracking pixel here, a chat widget there, one more A/B testing tool. A quantity budget catches exactly this pattern of many small additions, which a pure size budget can miss when each individual script stays under its own threshold.

6. Enforcing budgets in CI: failing the build on regression

A performance budget only has teeth once it makes the build fail as soon as a threshold is exceeded, exactly like a failing test. Lighthouse CI reads a budget.json for this, defining resourceSizes, resourceCounts, and timings per resource type. The lhci autorun command loads the page in the pipeline, compares the results against the budgets, and returns a non-zero exit code as soon as a value falls outside tolerance.

It's important to place the budget as a gate before the merge, not as a reporting step tacked on afterward. A job that only leaves a warning in the log gets ignored in practice as soon as deadline pressure rises. A job that blocks the merge forces a conscious decision: either fix the regression or explicitly raise the budget with a documented justification.


[
  {
    "path": "/*",
    "resourceSizes": [
      { "resourceType": "script", "budget": 180 },
      { "resourceType": "stylesheet", "budget": 60 },
      { "resourceType": "image", "budget": 300 },
      { "resourceType": "font", "budget": 100 },
      { "resourceType": "total", "budget": 700 }
    ],
    "resourceCounts": [
      { "resourceType": "script", "budget": 12 },
      { "resourceType": "third-party", "budget": 5 },
      { "resourceType": "font", "budget": 4 },
      { "resourceType": "total", "budget": 75 }
    ],
    "timings": [
      { "metric": "interactive", "budget": 3500 },
      { "metric": "largest-contentful-paint", "budget": 2900 },
      { "metric": "total-blocking-time", "budget": 300 }
    ]
  }
]

# .github/workflows/performance-budget.yml
name: Performance Budget

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse-budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build production assets
        run: npm run build

      - name: Run Lighthouse CI against budget.json
        run: npx lhci autorun --config=./lighthouserc.json
        env:
          LHCI_BUILD_CONTEXT__CURRENT_HASH: ${{ github.sha }}

      # lhci exits non-zero when a budget assertion fails,
      # which fails this job and blocks the merge automatically

7. Tooling: Lighthouse CI and webpack performance hints

Alongside Lighthouse CI, webpack ships its own ready-to-use budget feature: the performance configuration in webpack.config.js. performance.maxAssetSize and performance.maxEntrypointSize set byte-based upper limits for individual assets and for the entire entry point. The performance.hints option controls whether an overage merely shows as a warning in the build log or, with 'error', actually aborts the build.

The advantage of this approach: the budget is checked directly inside the build tool, not downstream in a separate CI stage, so developers see an overage locally while building, before the code is even pushed. The downside: webpack only checks bundle sizes, not timing or quantity metrics. For a complete picture, webpack performance hints and Lighthouse CI need to be combined, not used as a substitute for one another.


// webpack.config.js - fail the build on oversized bundles
module.exports = {
  // ...
  performance: {
    hints: 'error', // 'warning' | 'error' | false
    maxAssetSize: 180 * 1024,       // 180 KB per individual asset
    maxEntrypointSize: 250 * 1024,  // 250 KB for the critical entry point
    assetFilter: function (assetFilename) {
      // Only enforce the budget for JS and CSS, not source maps or fonts
      return /\.(js|css)$/.test(assetFilename) && !/\.map$/.test(assetFilename);
    }
  }
};

8. bundlesize and size-limit in the npm workflow

For projects without a full Lighthouse pipeline, the npm packages size-limit and bundlesize offer a faster path into enforced size budgets. Both read a configuration directly from package.json, where a maximum value in kilobytes is set per file or glob pattern. The npx size-limit command in the CI pipeline compares the built assets against these values and exits with an error code as soon as a limit is exceeded.

size-limit has an advantage over bundlesize in that it can additionally simulate execution time in the browser, not just raw file size, bringing it closer to a real timing budget. For a Hyvä theme, it's worth adding an entry per critical bundle, for example the Alpine.js bundle and the Tailwind CSS output, so an accidentally imported heavy library shows up immediately instead of only surfacing weeks later in the next Lighthouse report.


{
  "name": "hyva-theme-assets",
  "scripts": {
    "size": "size-limit",
    "size:ci": "size-limit --json > size-limit-report.json"
  },
  "size-limit": [
    {
      "name": "Alpine.js bundle",
      "path": "pub/static/frontend/**/js/alpine.min.js",
      "limit": "45 KB",
      "gzip": true
    },
    {
      "name": "Tailwind CSS output",
      "path": "pub/static/frontend/**/css/styles.css",
      "limit": "60 KB",
      "gzip": true
    },
    {
      "name": "Total critical entry point",
      "path": "pub/static/frontend/**/js/critical.min.js",
      "limit": "180 KB",
      "gzip": true
    }
  ],
  "devDependencies": {
    "@size-limit/preset-app": "^11.0.0",
    "size-limit": "^11.0.0"
  }
}

9. Communicating with stakeholders: protecting budgets

The most robust CI gate does little good if a product owner or marketing team can simply override the budget whenever "just one more script" is urgently needed. The most effective lever against this pattern is communicating the performance budget not as a technical detail, but as a fixed capacity, comparable to a weight limit in shipping: every kilobyte a new script adds must be saved somewhere else, there is no unlimited allowance.

In practice, this works through two measures. First, KB and milliseconds get translated into business metrics, for example "each additional second of LCP costs an estimated X percent conversion based on our data", instead of showing abstract technical numbers. Second, every exception to the budget needs a documented, visible approval in the same pull request process as a security exception, not a silent edit to budget.json. That turns every overage into a conscious, traceable decision instead of a quiet compromise.

The difference between a documented and an actually enforced performance budget shows up most clearly in the outcome once a team is under time pressure.

Aspect Without an enforced budget With an enforced budget Enforcement point
JS bundle size Grows unnoticed, step by step Stays within the defined KB limit webpack performance.maxAssetSize
LCP time Degrades over months Stays stable within the target range Lighthouse CI budget.json (timings)
Number of third-party scripts Grows with every marketing request Requires approval per new script Quantity budget + code review
Regression detection Only visible in the next audit Build fails immediately CI pipeline (lhci autorun)
Stakeholder communication Technical discussion after launch Documented approval before the merge Pull request process

Mironsoft

Performance budgets, CI/CD integration, and Hyvä optimization for Magento stores

Ready to roll out performance budgets properly?

We measure your Magento store's baseline, define realistic size, timing, and quantity budgets, and integrate them as a gate in your CI pipeline, so every regression is caught before the merge instead of in the next Lighthouse report.

Baseline audit

Multi-day measurement series with Lighthouse CI and WebPageTest as the basis for realistic budgets

CI integration

budget.json, webpack performance hints, and size-limit as a merge gate in your pipeline

Stakeholder playbook

Communication templates so budgets don't get quietly overridden by one more script

10. Summary

A performance budget solves a recurring problem: without a binding upper limit, bundle size, load time, and request count creep up until a store has become noticeably slower without any single commit being responsible. Size budgets cap JS, CSS, and images in kilobytes, timing budgets cap perceived metrics like LCP and TTI, and quantity budgets cap the number of requests, fonts, and third-party scripts. All three budget types should be derived from a real baseline measurement, not from arbitrary target numbers.

A budget only has an effect once it's enforced as a gate in the CI pipeline: budget.json for Lighthouse CI, performance.maxAssetSize in webpack, and size-limit in the npm workflow all fail the build as soon as a threshold is exceeded. Communicating with non-technical stakeholders matters just as much: framing performance budgets as a fixed capacity rather than a technical detail, and requiring every exception to go through documented approval, prevents budgets from being quietly bypassed under deadline pressure.

Defining and Enforcing Performance Budgets - The Essentials at a Glance

Size budgets

Cap compressed size per resource type, split into JS, CSS, and images, with a separate entry-point budget.

Timing & quantity budgets

Measure LCP/TTI under fixed throttling, count and cap requests and third-party scripts per page.

CI enforcement

budget.json, webpack performance.hints, and size-limit fail the build when a limit is exceeded.

Baseline & communication

Derive budgets from the median and 75th percentile of real measurements, require documented approval for exceptions.

11. FAQ: Defining and Enforcing Performance Budgets

1What exactly is a performance budget?
A binding, machine-checked upper limit on a measurable value like bundle size, LCP time, or request count, checked automatically in the build instead of merely recommended.
2How do size, timing, and quantity budgets differ?
Size caps file sizes in KB, timing caps perceived metrics like LCP in milliseconds, quantity caps the number of requests, fonts, or third-party scripts.
3How do I set realistic thresholds?
Multi-day baseline measurement with Lighthouse CI or WebPageTest. Set the budget just above the median/75th percentile and lower it incrementally.
4What belongs in a budget.json for Lighthouse CI?
resourceSizes, resourceCounts, and timings per path. lhci autorun checks all three arrays automatically against the measured values.
5How does performance.maxAssetSize work in webpack?
Sets a byte-based upper limit per asset, maxEntrypointSize caps the entire entry point. hints: 'error' aborts the build on an overage.
6What's the difference between bundlesize and size-limit?
Both check sizes from package.json. size-limit additionally simulates execution time in the browser, closer to a real timing budget.
7What happens when a build exceeds the budget?
Non-zero exit code, the job fails, the merge is blocked. Fix the regression or raise the budget with a documented justification.
8How often should a budget be updated?
Lower it slightly after every optimization. Full reassessment with a fresh baseline at least once per quarter.
9How do I communicate budgets to non-technical stakeholders?
Translate KB/ms into business metrics, e.g. conversion loss per second. Exceptions need documented approval in the pull request process.
10Should a third-party script blow the budget automatically?
No. It's checked against the budget like any other code change. On overage: save elsewhere or explicitly approve the addition.