Skip to content
4 min read

Catch Core Web Vitals regressions in CI

A Lighthouse run in CI turns performance into a test. How to set budgets that fail a pull request without failing on noise, and what to gate versus warn on.

On this page
  1. What CI can and cannot measure
  2. A workflow that is worth having
  3. Set thresholds you will actually honour
  4. What to do when it goes red
  5. The takeaway

Performance regressions do not arrive as a project. They arrive one pull request at a time: a font added here, a widget dropped into a layout there, a component that was on the server quietly moved to the client. Each one is small, none of them is anybody's fault, and six weeks later the page is a second slower with no single commit to blame.

The fix is unglamorous. Measure on every pull request, and fail the ones that make it worse.

What CI can and cannot measure

Be precise about this, because a check that claims more than it measures loses the team's trust the first time it disagrees with reality.

Field metric What CI can measure Honest?
LCP Lighthouse LCP in a lab run Close enough to catch regressions
CLS Lighthouse CLS Good, though it misses shifts caused by real interaction
INP Total Blocking Time as a proxy Related, not the same metric

CI answers one question well: did this change make the page worse than the last build? It does not answer are my users having a good time, because it is one machine on a simulated network. That second question belongs to field data, as I argued in the LCP guide — the lab tests a change, the field decides whether it mattered.

A workflow that is worth having

Build the app the way production builds it, serve it, run Lighthouse several times, assert:

.github/workflows/perf.ymlyaml
name: Performance
on: pull_request

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }

      - run: npm ci
      - run: npm run build

      - name: Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun

And the configuration that makes it mean something:

lighthouserc.jsjs
module.exports = {
  ci: {
    collect: {
      startServerCommand: "npm run start",   // the production build, never the dev server
      url: [
        "http://localhost:3000/",
        "http://localhost:3000/blog",
        "http://localhost:3000/blog/some-representative-post",
      ],
      numberOfRuns: 3,                        // median of three; one run is noise
    },
    assert: {
      assertions: {
        "largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
        "cumulative-layout-shift":  ["error", { maxNumericValue: 0.1 }],
        "total-blocking-time":      ["warn",  { maxNumericValue: 300 }],
        "resource-summary:script:size": ["error", { maxNumericValue: 200000 }],
        "unused-javascript":        ["warn",  { maxLength: 1 }],
      },
    },
    upload: { target: "temporary-public-storage" },
  },
};

Four details in there do most of the work.

Test the production build. A dev server is unminified, unbundled and carries a refresh runtime. Numbers from it are not wrong so much as unrelated.

Three runs, median. Lighthouse varies between runs on shared CI hardware. One run against a tight threshold produces a check that fails for no reason, and a check that fails for no reason gets re-run until it passes, which is the same as not having it.

A script size budget. resource-summary:script:size is the most stable assertion on the list — bytes do not vary with CPU contention. It also catches the most common regression: a dependency that quietly joined the client bundle. That is the number to watch if you only watch one, and finding what moved it is a separate exercise.

Pick representative URLs. Three pages that represent the real templates beat twenty that repeat one. A content page, an index, the home page.

Set thresholds you will actually honour

The common mistake is setting every budget at the "good" threshold on day one, watching the pipeline go red, and adding continue-on-error: true by Friday.

Set them from where you are:

  1. Run Lighthouse on main and record the medians.
  2. Set each threshold slightly above the current number — enough headroom that normal variance passes.
  3. Mark the two or three you would genuinely block a merge for as error. Everything else warn.
  4. Tighten the thresholds as the numbers improve, so they never drift back.

That last step is what makes this a ratchet rather than a dashboard. The budget follows the build down and never lets it climb back.

What to do when it goes red

A failing performance check should tell you what regressed, and the assertion name usually does. The common cases:

  • CLS jumped. Something renders without reserved space — an image without dimensions, a lazily-loaded block, a late-arriving banner. The causes are enumerated in how to fix Cumulative Layout Shift.
  • Script size jumped. A new dependency reached the client. Open the analyzer and find out which.
  • LCP jumped with no size change. Usually the element changed, or the render path did — a hero moved into a client component, a font stopped being preloaded.

Each of those is a ten-minute fix in the pull request that caused it, and a half-day investigation three months later. That gap is the entire argument for the check.

The takeaway

Core Web Vitals work tends to be done in bursts: a bad report, a week of fixes, a quiet regression back to where it started. CI replaces the bursts with a ratchet.

Keep it honest and it stays useful: production build, several runs, median, a handful of budgets you would genuinely block a merge for, and the rest as warnings. Then treat the field data — Search Console and CrUX — as the thing that decides whether any of it reached your users, because the lab never gets to say that.

Frequently asked

Can you test Core Web Vitals in CI?

You can test their lab equivalents. Largest Contentful Paint and Cumulative Layout Shift are measurable in a Lighthouse run, so a regression in either shows up before merge. Interaction to Next Paint needs real interaction, so CI uses Total Blocking Time as its proxy — related, but not the same metric.

How do I stop Lighthouse CI failing randomly?

Run the same URL three to five times and assert on the median rather than a single run. Pin the runner and the Chrome version, test a production build rather than a dev server, and set thresholds with headroom above your current numbers. Most flaky performance checks are a single run compared against a threshold that is too tight.

Should a performance budget block a merge?

A small number of them should. Pick the two or three that would genuinely make you reject the change — usually LCP, CLS and total JavaScript — and set those to error. Everything else should warn, so the signal stays meaningful and the team does not learn to skip a red check.

Is a Lighthouse score in CI the same as what Google measures?

No. Google assesses the 75th percentile of real visits from the Chrome User Experience Report. A CI run is one machine on a simulated network, so the absolute numbers will differ. Use CI to detect change and field data to decide whether your users are actually affected.

Found this useful?Share on XShare on LinkedIn

Related reading