A pull request added an analytics vendor and merged green, and only after deploy did Total Blocking Time and third-party transfer size regress — because nothing in CI was asserting the budget on pull requests.
Triage: Confirm the Regression Is Real and Reproducible Locally
Before touching workflow YAML, prove the regression exists and that Lighthouse can see it. A CI gate is worthless if it fires on noise, so reproduce deterministically first.
- Check out the merge commit that introduced the regression and build the site exactly as production does (
npm ci && npm run build). - Run Lighthouse CI locally against the built output with the same settings CI will use, so the numbers are comparable:
npx lhci autorun --collect.numberOfRuns=3
- Read the console summary. Note the observed
total-blocking-timeand thethird-party-summarymain-thread value, and open the uploaded report to see the per-vendor breakdown. - Check out the parent commit (before the vendor was added), rerun, and diff the two. A real regression shows a consistent delta across all three runs, not a single-run spike.
If the delta only appears in one run, it is variance, not a regression — raise numberOfRuns and re-confirm before building a gate around it. Determinism at this stage is non-negotiable: a gate you cannot reproduce on demand will generate flaky red checks, contributors will learn to ignore or re-run it, and its authority evaporates. Pin throttlingMethod: "simulate" rather than "devtools" for CI, because simulated throttling models the network and CPU mathematically instead of relying on the runner’s live conditions, which vary with load on the shared GitHub-hosted machine.
One more triage decision: run Lighthouse against the built preview output, not the dev server. Dev servers ship unminified modules, disable long-term caching, and inject hot-reload clients — all of which inflate transfer size and blocking time and would make the third-party regression harder to isolate from first-party dev noise. Reproduce against npm run build && npm run preview so the bytes Lighthouse weighs are the bytes users would actually download.
Root Cause: No Assertion Gate Runs on Pull Requests
The regression merged because the repository had no job that ran Lighthouse assertions on the pull request. Lighthouse might have been run manually, or in a nightly job that reports but never fails, so a byte-heavy vendor addition had nothing standing in its way at review time.
There is an important distinction between collecting Lighthouse data and asserting on it. Many teams add a Lighthouse job that uploads a report and posts a score, then treat the score as advisory. A report that never returns a non-zero exit code cannot block a merge — it is documentation, not a gate. The lhci assert phase is what converts a measurement into a pass/fail verdict, and only that verdict, surfaced as a required GitHub status check, stops the regression. A nightly report also arrives after the fact: by the time it runs, the vendor is already on main and, often, already deployed. The gate has to run on the pull request, while rejecting the change is still cheap.
The fix is structural: run lhci autorun inside a GitHub Actions workflow triggered on pull_request, with an assert block and a budget.json that returns a non-zero exit code on breach. GitHub marks the check failed, and branch protection can require it before merge. The full budget model — the assert preset, budget.json structure, and the performance-budget audit — is covered in the governing guide, Enforcing Performance Budgets with Lighthouse CI; this page is specifically about wiring that gate into GitHub Actions. Set the actual threshold values from field data captured via RUM attributed to third-party vendors so the gate reflects real user impact rather than a synthetic guess.
Resolution: A Pull-Request Workflow That Fails on Budget Breach
Add two files. First, lighthouserc.json at the repository root, defining how to collect and what to assert:
{
"ci": {
"collect": {
"numberOfRuns": 3,
"startServerCommand": "npm run preview -- --port=4173",
"startServerReadyPattern": "Local:",
"url": ["http://localhost:4173/"],
"settings": {
"preset": "desktop",
"throttlingMethod": "simulate",
"budgetsPath": "./budget.json"
}
},
"assert": {
"preset": "lighthouse:no-pwa",
"assertions": {
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
"third-party-summary": ["error", { "maxNumericValue": 250 }],
"performance-budget": ["error", { "minScore": 1 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Second, budget.json, which caps the third-party payload that regressed:
[
{
"path": "/*",
"resourceSizes": [
{ "resourceType": "third-party", "budget": 200 },
{ "resourceType": "script", "budget": 350 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 12 }
]
}
]
Now the workflow at .github/workflows/lighthouse-ci.yml. This uses treosh/lighthouse-ci-action, which bundles Chrome and reads the same lighthouserc.json:
name: Lighthouse CI
# Run on every pull request targeting main, plus pushes to main
# so the baseline is always measured too.
on:
pull_request:
branches: [main]
push:
branches: [main]
# Cancel superseded runs on the same PR to save runner minutes.
concurrency:
group: lhci-${{ github.ref }}
cancel-in-progress: true
jobs:
lighthouse:
name: Assert performance budgets
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Run Lighthouse CI and assert budgets
uses: treosh/lighthouse-ci-action@v12
with:
# Reuse the committed config so local and CI runs are identical.
configPath: ./lighthouserc.json
# Fail the job (and the PR check) when any assertion breaches.
uploadArtifacts: true
temporaryPublicStorage: true
The action inherits the non-zero exit code from lhci assert, so a breached third-party budget fails the lighthouse job. If you would rather not add an action dependency, the equivalent final step is a plain command — functionally identical because both run the same lighthouserc.json:
- name: Run Lighthouse CI (no action wrapper)
run: npx --yes @lhci/cli@0.14.x autorun
A note on where Lighthouse collects from. The workflow above builds the site inside the job and lets lhci start its own preview server, which keeps every run hermetic and independent of deploy timing. The alternative is to point collect.url at a deployed preview URL — a Netlify or Vercel deploy preview for the pull request. That measures the real CDN, compression, and edge caching, which is more faithful, but it couples the check to the preview deploy finishing first and requires the URL to be discoverable at job time. For catching third-party byte regressions, the self-hosted preview is simpler and sufficient, because the vendor payload is identical regardless of who serves your first-party HTML.
If you outgrow temporary-public-storage (reports expire after a few days), stand up a Lighthouse CI server and set upload.target to lhci with a serverBaseUrl and token stored in secrets. That gives you historical trend lines so you can see third-party size drifting upward across many pull requests, not just whether a single build breached. Reference the token in the workflow via ${{ secrets.LHCI_TOKEN }} rather than committing it.
Finally, make the check mandatory. In the repository settings under Branches → Branch protection rules for main, enable Require status checks to pass before merging and select Assert performance budgets. Without this, the check can go red and a maintainer can still merge. Pair the required check with the treosh/lighthouse-ci-action GitHub App or a github-token input so the action posts the report link and per-metric deltas as a status directly on the pull request, giving reviewers the numbers inline instead of buried in job logs.
Verification: A Failed Check Annotation on the PR
Confirm the gate works by regressing it on purpose. On a throwaway branch, add a heavy third-party tag (or lower the third-party budget to 1 KB) and open a pull request. Within a minute or two the Assert performance budgets check turns red, and expanding it shows the annotation from lhci assert:
Assertion failed: performance-budget failure
Expected: minScore >= 1
Found: 0.5
Assertion failed: third-party-summary failure
Expected: maxNumericValue <= 250
Found: 412 (main-thread ms)
Done running Lighthouse!
1 result(s) for http://localhost:4173/ failed
The check summary links to the temporary-public-storage report, where the third-party-summary audit lists the offending vendor by name. Revert the deliberate regression, push, and confirm the same check returns green — proving the gate distinguishes a real breach from a clean build.
Common Pitfalls
- Running only on
push, never onpull_request. Apush-only trigger measuresmainafter the merge has already happened — too late to block anything. Trigger onpull_requestso the assertion runs against the proposed change while it can still be rejected. - Leaving the check optional. A red Lighthouse job that is not a required status check is advisory only; contributors merge past it. Add it to branch protection, and gate hard byte/count budgets with
errorwhile leaving genuinely noisy timings like LCP onwarn. - Asserting against a stale or missing
budgetsPath. Ifsettings.budgetsPathis absent, theperformance-budgetaudit has nothing to evaluate and passes trivially, so the third-party size cap is silently unenforced. Confirm the path resolves and thatbudget.jsonis a top-level array. - Unpinned Chrome or action versions. Floating
@lateston either@lhci/cliortreosh/lighthouse-ci-actionlets a background Chrome update shift metric values, producing a red check that no code change caused. Pin the action to a major version (@v12) and the CLI to a minor (@lhci/cli@0.14.x) so metric behaviour only changes when you choose to upgrade. - Skipping
npm ci/npm run buildbefore collect. Running Lighthouse against adist/from a previous job, or against an uninstalled tree, measures stale or broken output. Always install and build fresh in the same job so the assertion reflects the pull request’s actual code.