A performance budget is only real if something enforces it. Documentation in a wiki that says “third-party JavaScript must stay under 200 KB” does nothing to stop the next pull request that adds a chat widget, an A/B testing SDK, and a heatmap recorder. Lighthouse CI (@lhci/cli) turns that written intention into a machine-checked gate: it collects Lighthouse runs in your continuous integration pipeline, compares the results against declared thresholds, and returns a non-zero exit code when a build breaches them. The pull request goes red, the reviewer sees exactly which metric regressed, and the regression never reaches production.
This guide covers the mechanics of Lighthouse CI as a budget enforcer specifically for third-party script performance: the assert preset that guards Core Web Vitals metrics, the budget.json file that caps resource sizes and counts per origin, and the third-party-summary audit that attributes main-thread blocking to individual vendors. By the end you will have a lighthouserc.json configuration that fails a build when third-party JavaScript inflates Total Blocking Time, transfer size, or resource count beyond agreed limits.
Prerequisites and When to Apply This Guide
Reach for Lighthouse CI budget enforcement when your team has agreed on numeric performance targets and needs them defended automatically rather than by manual review. Apply it when all of the following hold:
- You run a CI pipeline (GitHub Actions, GitLab CI, CircleCI, Jenkins) that can execute Node.js and headless Chrome.
- You can serve the built site — either statically (
lhci autorunstarts its own server) or against a deployed preview URL. - Third-party scripts are a meaningful share of your payload, so a regression is likely to arrive as a dependency or tag-manager change rather than a first-party code change.
- You already measure the metrics you intend to gate. If you have not yet instrumented vendor scripts, establish observability first with PerformanceObserver instrumentation so your budget numbers reflect real field behaviour rather than guesses.
Budgets defended in CI are a synthetic, pre-merge signal. They complement — they do not replace — field data from real-user monitoring attributed to third-party vendors. Use the synthetic gate to stop obvious regressions cheaply; use RUM to catch what only appears at scale.
The assert and budget.json Contract
Lighthouse CI has two distinct enforcement surfaces, and confusing them is the most common configuration mistake.
The assert block in lighthouserc.json guards Lighthouse audit scores and numeric values. You point it at audit IDs (total-blocking-time, largest-contentful-paint, third-party-summary) and declare a maxNumericValue or minScore. The assertions can be authored by hand, or you can extend a preset — lighthouse:recommended (strict, every audit) or lighthouse:no-pwa — and then override individual audits.
The budget.json file is a separate, standardised artifact defined by the Lighthouse performance budgets format. It caps three things:
timings— millisecond ceilings for metrics such asinteractive,total-blocking-time,first-contentful-paint, andlargest-contentful-paint.resourceSizes— kilobyte ceilings per resource type:script,third-party,stylesheet,image,total, and others.resourceCounts— a maximum number of requests per resource type, includingthird-party.
The third-party resource type is the one that matters here. It aggregates every request Lighthouse classifies as cross-origin third-party, so a single resourceSizes entry caps the combined transfer weight of every vendor tag on the page. The dedicated transfer-size budget guide covers the resourceSizes mechanics in depth, including host-pattern budgets that target one vendor at a time.
Crucially, budget.json is loaded through the assert layer. You reference it with the budgetsFile (older) or, more robustly, wire budgets as an assertion on the performance-budget audit. Lighthouse runs the audit, the audit reads your budget file, and a breach becomes an assertion failure with a non-zero exit code.
Implementation: A Budget-Enforcing Lighthouse CI Setup
Step 1 — Install @lhci/cli
Install the CLI as a dev dependency so the version is pinned and reproducible across every CI run:
npm install --save-dev @lhci/cli
Verify the binary resolves and pin the Chrome it drives — CI runners already ship a headless Chrome, so no extra download is usually needed:
npx lhci --version
Step 2 — Write lighthouserc.json
Create lighthouserc.json at the repository root. This file drives all three lhci phases: collect (run Lighthouse), assert (check thresholds), and upload (store reports).
{
"ci": {
"collect": {
"url": ["http://localhost:4173/"],
"startServerCommand": "npm run preview",
"startServerReadyPattern": "Local:",
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"throttlingMethod": "simulate"
}
},
"assert": {
"preset": "lighthouse:no-pwa",
"assertions": {
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"third-party-summary": ["warn", { "maxNumericValue": 250 }],
"performance-budget": ["error", { "minScore": 1 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Two details make this reliable. numberOfRuns: 3 runs Lighthouse three times and reports the median, which damps the natural variance in synthetic metrics — especially LCP. And the performance-budget assertion set to ["error", { "minScore": 1 }] means: if any entry in budget.json is breached, the audit score drops below 1 and the build fails.
Step 3 — Define budget.json
Create budget.json at the repository root. This example caps third-party weight and count while leaving first-party headroom:
[
{
"path": "/*",
"timings": [
{ "metric": "interactive", "budget": 3800 },
{ "metric": "total-blocking-time", "budget": 200 }
],
"resourceSizes": [
{ "resourceType": "third-party", "budget": 200 },
{ "resourceType": "script", "budget": 350 },
{ "resourceType": "total", "budget": 1000 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 12 }
]
}
]
budget values in resourceSizes are kilobytes of transfer size (compressed over the wire), not raw bytes and not uncompressed size. The third-party cap of 200 therefore means “no more than 200 KB of compressed third-party payload across the whole page.”
Step 4 — Point the Assertion at the Budget File
Tell the collect settings where the budget lives so the performance-budget audit can read it:
{
"ci": {
"collect": {
"settings": {
"budgetsPath": "./budget.json"
}
}
}
}
Merge this settings fragment into the collect block from Step 2. With budgetsPath set, every Lighthouse run evaluates the budget, and the performance-budget assertion from Step 2 converts any breach into a failed check.
Step 5 — Run lhci autorun
lhci autorun chains collect, assert, and upload in one command — ideal for CI:
npx lhci autorun
Add a convenience script so both local developers and CI invoke the same entry point:
{
"scripts": {
"lhci": "lhci autorun"
}
}
Run npm run lhci locally before pushing. A non-zero exit code means a threshold or budget was breached; the console prints the exact audit, the observed value, and the expected ceiling.
Verification Checklist
Interaction with Instrumentation and RUM Attribution
Lighthouse CI is the enforcement layer of a three-part measurement stack, and it works best when the other two feed it realistic numbers.
Set your budget thresholds from field data, not from a single synthetic run. The total-blocking-time and third-party-summary ceilings you assert here should be informed by what PerformanceObserver instrumentation of third-party scripts records in production — the longtask and resource entries that reveal which vendor actually blocks the main thread. A budget derived from live attribution is defensible; a budget picked from one lucky Lighthouse run will either never fire or fire constantly.
When the gate does fire, RUM data attributed to third-party vendors tells you whether the synthetic regression matters for real users. A 30 ms TBT increase that Lighthouse flags might be invisible in the field, or it might correlate with a real INP degradation your RUM already tracks. Treat the CI gate as the fast, cheap signal and RUM as the ground truth that calibrates it.
Because Lighthouse’s third-party-summary and the network-level budgets both key off which requests are third-party, the same connection-warming and loading decisions you make for the network waterfall of external assets directly move these numbers. Deferring a vendor tag reduces its blocking-time contribution; that reduction shows up in the very audit this gate asserts on.
Troubleshooting
performance-budget audit passes even though a vendor is clearly over budget
Symptom: You added a large third-party script but lhci autorun exits 0 and the budget audit scores 1.
Cause: budgetsPath is not set in the collect.settings, so Lighthouse ran the performance-budget audit with no budget to evaluate. An audit with no budget trivially passes.
Fix: Confirm settings.budgetsPath points at the correct relative path (./budget.json) and that the file is valid JSON — a top-level array, not an object. Run npx lighthouse http://localhost:4173 --budget-path=./budget.json --view once locally and check the “Budgets” section renders with rows.
Assertion failures on largest-contentful-paint that flip between runs
Symptom: The build fails intermittently on LCP with values hovering just above maxNumericValue, then passes on re-run.
Cause: Synthetic LCP is inherently noisy on shared CI runners; a single run can swing several hundred milliseconds under CPU contention.
Fix: Raise numberOfRuns to 5, keep throttlingMethod: "simulate" for determinism, and set the LCP assertion to warn rather than error if the metric is genuinely borderline. Reserve error for metrics with a stable enough signal to gate on, and gate hard-limit budgets (bytes, counts) rather than noisy timings where you can.
Unable to determine Chrome version or Chrome fails to launch in CI
Symptom: lhci collect aborts before producing a report with a Chrome launch error.
Cause: No headless Chrome is available on the runner, or it needs sandbox flags in a container.
Fix: Add Chrome flags in collect.settings.chromeFlags: "--no-sandbox --headless=new". On GitHub-hosted runners Chrome is preinstalled; on self-hosted or container runners, install google-chrome-stable (or use the treosh/lighthouse-ci-action, which bundles it) as covered in the GitHub Actions configuration guide.
startServerReadyPattern never matches and collect hangs
Symptom: lhci collect waits indefinitely, then times out.
Cause: The startServerReadyPattern regex does not appear in your dev/preview server’s stdout, so LHCI never believes the server is ready.
Fix: Run npm run preview manually and copy an exact substring of the “server started” line into startServerReadyPattern (for Vite preview, "Local:" works; for serve, "Accepting connections"). Ensure the url port matches the port the server actually binds.
Frequently Asked Questions
Should I put my third-party size limit in the assert block or in budget.json?
Put resource size and count limits in budget.json — that file exists precisely to express resourceSizes and resourceCounts per resource type, including third-party. Use the assert block for audit-level thresholds such as total-blocking-time and largest-contentful-paint, plus the single performance-budget assertion that makes any budget.json breach fail the build. The two layers are complementary, not redundant.
Does the third-party budget count first-party CDN assets served from a subdomain?
Lighthouse classifies “third-party” by comparing each request’s origin against the main document’s origin using its entity map. A subdomain of your own registrable domain is generally treated as first-party, so assets on cdn.yoursite.com usually do not count toward the third-party bucket while cdn.vendor.com does. If a specific CDN is being misclassified, target it explicitly with a host-pattern budget as described in the transfer-size budget guide.
Why use lhci autorun instead of separate collect and assert commands?
autorun is a convenience wrapper that runs collect, then assert, then upload, sharing configuration and the run directory between them. For CI it means one command and one exit code. Use the separate commands only when you need to interleave other steps — for example collecting against a preview URL in one job and asserting in another. Functionally the thresholds and budgets behave identically either way.
Can a single Lighthouse CI budget cover multiple routes with different limits?
Yes. budget.json is an array of objects, each with its own path glob. A "path": "/checkout/*" object can allow a smaller third-party budget than a "path": "/*" fallback. Lighthouse applies the most specific matching path to each collected URL, so you can hold conversion-critical routes to a tighter third-party ceiling than marketing pages.