/ sagar_pansuriya
§ DX Tools

Frontend Performance Budgets That Actually Get Enforced

Illustration of a bundle size bar chart crossing a dashed budget line next to a list of CI checks with one failing

Most teams I've worked with have had a performance budget at some point. It usually lives in a Notion page or a kickoff slide: "JS under 200 KB, LCP under 2.5 seconds." Everyone agrees. Six months later the homepage ships 600 KB of JavaScript, and nobody can say which pull request did it, because every single one only added "a little bit".

That's the real problem with budgets. The numbers are rarely wrong. They just aren't enforced. A budget that doesn't fail a build is a wish.

This post covers how I'd set one up so it actually holds: which metrics to budget, how to pick numbers from real user data instead of guessing, how to wire Lighthouse CI and size-limit into GitHub Actions, how to see what's inside a Vite bundle, and what the team should do when the check goes red.

Pick metrics you can measure on every pull request

There are two kinds of budget, and you want both:

  • Quantity budgets: kilobytes of JavaScript and CSS, number of requests, image weight. They're deterministic. The same commit always produces the same number, so they make excellent CI gates.
  • Timing budgets: LCP, CLS, and a proxy for responsiveness. They're what users actually feel, but lab measurements are noisy, so they need more careful thresholds.

One important catch: INP can't be measured in CI. It needs real interactions from real users, and Lighthouse only loads the page. In the lab, Total Blocking Time (TBT) is the usual stand-in. It's not the same metric, but a page with lots of long tasks at load will tend to have worse INP, so budgeting TBT catches the worst regressions. Keep tracking INP itself from field data.

A sensible starting set:

MetricTypeWhere it's enforced
JS size (compressed) per entryQuantitysize-limit, every PR
CSS size (compressed)Quantitysize-limit, every PR
Script request countQuantityLighthouse CI
LCP (lab)TimingLighthouse CI
CLS (lab)TimingLighthouse CI
TBT (lab proxy for INP)TimingLighthouse CI
LCP, INP, CLS at p75FieldRUM dashboard, reviewed weekly

Set numbers from real users, not round numbers

Budgets picked out of thin air fail in one of two ways. Too strict, and the first PR breaks them, so people learn to ignore the check. Too loose, and they never trip, so they catch nothing.

Start from what you have:

  1. Get field data. PageSpeed Insights shows CrUX data for sites with enough traffic. For everything else, the web-vitals library sending p75 LCP, INP and CLS to your own endpoint is enough.
  2. Compare against the thresholds. Google's "good" lines are LCP at 2.5 s or less, INP at 200 ms or less, and CLS at 0.1 or less, all at the 75th percentile.
  3. Record today's lab and bundle numbers. Run a production build and a few Lighthouse runs, and write down the current sizes and timings.
  4. Set the budget just above today. If you're already in "good" territory, budget roughly 5 to 10 percent above the current number so normal noise passes but real growth fails. If you're not, budget at today's number and ratchet it down as you fix things.

The point of the first budget isn't to be ambitious. It's to stop things getting worse. Improvement comes from ratcheting.

Pipeline diagram: a pull request triggers a Vite build, size-limit checks bundle sizes, Lighthouse CI checks lab LCP, CLS and TBT, and the result passes or fails the PR; production field data at p75 feeds back into the budget numbers

Bundle size checks with size-limit

Bundle size is the cheapest, most reliable gate, so start there. size-limit measures built files and fails if they're over the limit. Install it with the file plugin:

npm install --save-dev size-limit @size-limit/file

Then point it at your build output. For a Laravel app using Vite, hashed files land in public/build/assets, and globs handle the hashes:

// package.json
{
  "scripts": {
    "build": "vite build",
    "size": "size-limit"
  },
  "size-limit": [
    { "name": "App JS",  "path": "public/build/assets/app-*.js",  "limit": "85 kB" },
    { "name": "App CSS", "path": "public/build/assets/app-*.css", "limit": "18 kB" },
    { "name": "Admin JS", "path": "public/build/assets/admin-*.js", "limit": "160 kB" }
  ]
}

Budget per entry point, not the whole folder. The marketing pages and the admin panel have very different users and very different acceptable weights. size-limit reports compressed sizes, which is what actually goes over the wire, so make sure you compare it with compressed numbers and not the raw figures from your editor.

There are community GitHub Actions that post size-limit results as a PR comment showing the diff against the base branch. That comment alone changes behaviour: people see "+14 kB" next to their name before a reviewer has to say anything.

Lighthouse CI with assertions

Lighthouse CI runs Lighthouse against URLs you choose, several times, and asserts on the results. Configuration lives in lighthouserc.json at the repo root:

{
  "ci": {
    "collect": {
      "startServerCommand": "php artisan serve --port=8000",
      "startServerReadyPattern": "Server running",
      "url": [
        "http://127.0.0.1:8000/",
        "http://127.0.0.1:8000/pricing",
        "http://127.0.0.1:8000/blog/example-post"
      ],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500, "aggregationMethod": "median-run" }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1, "aggregationMethod": "median-run" }],
        "total-blocking-time": ["warn", { "maxNumericValue": 250, "aggregationMethod": "median-run" }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
        "resource-summary:script:count": ["warn", { "maxNumericValue": 12 }],
        "resource-summary:third-party:count": ["warn", { "maxNumericValue": 5 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

A few choices worth explaining:

  • The numbers are placeholders. Lighthouse's default mobile run simulates a slow device and network, so lab LCP is usually worse than your field p75. Replace these with your own baseline from the previous section.
  • Three runs, median. One Lighthouse run on a shared CI runner is noisy. The median of three smooths out most of it without making the job slow.
  • error vs warn. Hard-fail on the stable metrics (LCP, CLS, script size). Warn on the noisier ones like TBT until you trust the numbers, then promote them.
  • Sizes are in bytes. resource-summary assertions use transfer size in bytes, so 180000 is roughly 176 KB.
  • Pick representative URLs. The homepage, one template-heavy page and one content page usually cover most regressions. Don't test twenty URLs on every PR.

The temporary-public-storage target uploads reports to a public URL that's kept for a limited time, which is handy for clicking through from a failed check. If your pages contain anything private, run your own Lighthouse CI server instead or skip the upload.

The GitHub Actions workflow

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

on: pull_request

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

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: composer install --no-interaction --prefer-dist
      - run: cp .env.example .env && php artisan key:generate
      - run: npm ci
      - run: npm run build

      - name: Bundle size
        run: npm run size

      - name: Lighthouse CI
        run: npx @lhci/cli autorun

Bundle size runs first because it's fast and deterministic. If it fails, you get the answer in seconds without waiting for Lighthouse. Your app will likely need a database or seeded data for pages to render meaningfully; an SQLite file and a seeder in the workflow is usually the simplest way to get realistic pages in CI.

See what's actually in the bundle

When a size check fails, the next question is always "what got bigger?" Vite's build output already lists every chunk with its compressed size, which is often enough. For a proper picture, add rollup-plugin-visualizer behind an environment flag:

// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true }),
        process.env.ANALYZE && visualizer({
            filename: 'storage/bundle-stats.html',
            template: 'treemap',
            gzipSize: true,
            brotliSize: true,
        }),
    ],
});

Run ANALYZE=1 npm run build and open the HTML file. The treemap makes the usual suspects obvious: a date library with every locale, a whole icon set imported for three icons, a charting library loaded on pages that don't have charts. Most fixes are a named import or a dynamic import() so the heavy code only loads where it's used.

What the team does when a budget breaks

This is the part that decides whether the budget survives. Tooling is the easy half. Agree on the process up front, before the first red check:

  • A red budget blocks the merge, just like a failing test. No "we'll fix it later". Later never comes.
  • The author investigates first. Usually the visualizer shows the cause in a minute, and the fix is a lazy import or a lighter dependency.
  • Raising a budget is allowed, but explicit. It's a change to package.json or lighthouserc.json in the same PR, with a sentence explaining why. A reviewer who owns performance signs it off. Sometimes the feature really is worth 20 KB, and that's a fine decision to make on purpose.
  • Ratchet down after wins. When someone cuts 30 KB, lower the budget in the same PR so the win is locked in.
  • Review field data regularly. Once a month, compare real-user p75 numbers with the lab budgets. If the lab passes but the field is getting worse, your test URLs or throttling don't reflect real users.
The goal isn't a perfect score. It's making every regression a visible, deliberate decision instead of an accident nobody noticed.

A checklist to keep

  1. Budget both quantities (JS, CSS, request counts) and timings (LCP, CLS, TBT).
  2. Track INP from real users; use TBT as the lab proxy in CI.
  3. Set first budgets just above today's numbers, based on field and lab data.
  4. size-limit per entry point on every PR, with a diff comment if you can.
  5. Lighthouse CI with three runs, median aggregation, errors on stable metrics.
  6. Keep rollup-plugin-visualizer one env flag away.
  7. Red blocks merge; raising a budget needs a reason and a reviewer; wins get ratcheted in.

None of this is complicated, and that's the point. A budget that lives in CI and fails loudly will do more for your site's speed than any number of performance sprints.

Discussion