Skip to content

Performance budgets belong in CI, not in a retrospective

A Lighthouse score is a snapshot. A budget that fails the build is a constraint. Here is the setup we ship with every front-end.

Sofia Lindqvist

Sofia Lindqvist

Staff Frontend Engineer

5 min read
Share

Every team has run a Lighthouse audit, celebrated a 96, and watched it drift to 71 over the following quarter. The audit was not wrong. It was just a photograph of a moving thing.

Performance is not a score you achieve. It is a constraint you enforce, and the only place a constraint holds is in the pipeline.

Budget the bytes, not the score

Lighthouse scores are noisy — they move with machine load, network conditions and the phase of the moon. Failing a build on a composite score produces flaky CI and, within two weeks, a team that skips the check.

Budget the things that are deterministic:

JSON
{
  "budgets": [
    {
      "path": "/*",
      "resourceSizes": [
        { "resourceType": "script", "budget": 180 },
        { "resourceType": "stylesheet", "budget": 60 },
        { "resourceType": "font", "budget": 120 },
        { "resourceType": "total", "budget": 500 }
      ]
    }
  ]
}

Bundle size is stable across runs. If the JavaScript budget goes from 178KB to 214KB, something specific happened in this pull request, and the diff is right there.

Keep the Lighthouse run, but report it rather than gate on it. Gate on bytes.

Attribute the regression in the PR

A build that fails with "bundle too large" sends someone on a twenty-minute hunt. A build that fails with "+34KB — date-fns added to the client bundle" is fixed in two minutes.

Emit a comparison against the base branch as a PR comment: per-chunk deltas, and the new dependencies responsible. The point is to make the cheapest moment to fix a regression also the most obvious one.

The four things that actually regress

Across the front-ends we have built, budget failures cluster:

A date or utility library reaching the client. Almost always importable server-side only, or replaceable with Intl. Intl.DateTimeFormat covers the majority of what teams reach for date-fns to do.

An icon set imported wholesale. import * as Icons pulls in thousands. Bundle the icons you use explicitly — and remember that dynamically named icons (:name="item.icon") are invisible to static scanners, so they need listing by hand.

Fonts. Self-host, subset to the ranges you serve, preload only the weights used above the fold. Four weights of two families at full Latin coverage is 300KB nobody asked for.

Images without dimensions. Not a byte problem, a layout-shift problem, and the one most likely to make a fast site feel slow. Width and height on every image, always.

Measure the field, not the lab

Lab metrics tell you whether a change helped. Field metrics tell you whether your users are having a good time. They disagree more often than people expect, because your laptop is not a mid-range Android on a train.

Ship web-vitals, send p75 to your analytics, and put LCP, INP and CLS on a dashboard someone looks at weekly. When lab and field diverge, the field is right.

Reasonable starting numbers

For a content-led marketing site, these are achievable without heroics:

MetricTarget (p75, field)
LCP< 1.5s
INP< 150ms
CLS< 0.05
JS transferred< 180KB
Total transferred< 500KB

An application will need looser numbers. Set them deliberately rather than inheriting them, and write down why.

The part that is cultural

The technical setup takes an afternoon. Keeping it takes agreement that a budget failure blocks a merge like a failing test does.

Without that, the check becomes advisory, advisory becomes ignored, and you are back to running an audit every quarter and wondering where the score went.

  • #Performance
  • #CI
  • #Nuxt
Share

Related reading

Engineering8 min read

Interactive by exception

A static article ships no JavaScript for its body. Every live example changes that, so each one should be an argument the prose cannot make on its own.

Sofia LindqvistSofia Lindqvist
Engineering6 min read

Your component API is a contract, so write it down

Variant tables, prop naming and the boolean that should have been an enum. Notes from maintaining component libraries other people have to use.

Sofia LindqvistSofia Lindqvist

One useful email a month

Design system patterns, front-end techniques and notes from the learn platform. No promotions, no digest of other people’s links.

Unsubscribe anytime. We never share your address.