Lighthouse CI budget enforcement for image weight
The fastest way to stop image bloat is to make the build refuse to go green when it happens. Lighthouse CI (@lhci/cli) runs a real Lighthouse audit in headless Chrome on every commit, compares the result against a budget you commit to the repo, and exits non-zero when total image bytes or the image request count crosses your ceiling — which fails the pull request check. This guide is part of Monitoring & Regression for Media Delivery within CDN & Edge Media Delivery, and it walks through the exact lighthouserc.json, budget.json, and GitHub Actions job that turn an image-weight budget into a merge gate.
Prerequisite checklist
Establish the baseline first: a budget set below today’s real image weight fails on the first run and trains everyone to ignore the gate. Pull current numbers from a production Lighthouse run, then set the ceiling roughly 10–15% above it.
How the budget gate works
Lighthouse produces two kinds of pass/fail signal, and image weight can be enforced through either. The distinction is the single most common source of confusion, so it is worth being precise.
A budget (budget.json, the LightWallet format) asserts on the performance-budget and resource-summary audits. It groups resources by type — image, script, font, total — and lets you cap the transferred kilobytes (resourceSizes) and the request count (resourceCounts) per group. This is the natural home for “no more than 500 KB of images and no more than 15 image requests.”
An assertion (the assert block in lighthouserc.json) asserts on any individual Lighthouse audit by id — largest-contentful-paint, total-byte-weight, uses-optimized-images — with a comparison operator and threshold. This is where outcome metrics like LCP belong.
You want both: the budget caps the cause (image bytes), the assertion caps the effect (LCP). The diagram shows how a single lhci autorun invocation fans out to both checks and how either one failing fails the whole run.
Exact solution
Step 1 — Install the CLI
# Install the Lighthouse CI CLI as a dev dependency so the version is
# locked in package-lock.json and reproducible across CI runs.
npm install --save-dev @lhci/[email protected]
# Sanity-check locally against a running dev server before wiring CI.
npx lhci autorun --collect.url=http://localhost:3000/
Step 2 — Write budget.json
This is the LightWallet budget. It caps the cause — image transfer size and image request count. Place it at the repo root.
// budget.json — one budget object per URL pattern.
// "path": "/*" applies to every audited URL; add more objects
// with specific paths to give the hero-heavy home page its own ceiling.
[
{
"path": "/*",
"resourceSizes": [
// TRANSFER size in KILOBYTES (not bytes). "image" covers every
// resource Lighthouse classifies as an image, including AVIF/WebP.
{ "resourceType": "image", "budget": 500 },
// A total-page ceiling catches non-image bloat sneaking in too.
{ "resourceType": "total", "budget": 1600 }
],
"resourceCounts": [
// Cap the NUMBER of image requests. A jump here usually means a
// carousel or icon sprite was added un-optimized.
{ "resourceType": "image", "budget": 15 },
{ "resourceType": "third-party", "budget": 10 }
]
}
]
Warning: resourceSizes is measured in kilobytes, not bytes — writing "budget": 500000 sets a 500 MB ceiling that never trips. This off-by-1000 is the most common reason a budget silently never fires.
Step 3 — Write lighthouserc.json
The runner config points lhci autorun at the URLs, loads the budget, and adds the LCP assertion. Note how budgetsPath and assertions coexist — the budget handles bytes, the assertions handle outcome audits.
// lighthouserc.json
{
"ci": {
"collect": {
// Serve the built static site directly — no app server needed.
// For an SSR app, replace with "startServerCommand": "npm start".
"staticDistDir": "./dist",
"url": [
"http://localhost/index.html",
"http://localhost/products/index.html"
],
// Run 3 times per URL and report the MEDIAN. Synthetic runs are
// noisy; a single run flaps the gate on network/CPU jitter.
"numberOfRuns": 3,
"settings": {
// Pin the form factor and throttling so numbers are comparable
// across commits. "mobile" applies the standard 4x CPU + slow-4G.
"preset": "desktop",
"chromePath": "/opt/chrome/chrome" // pinned build — see Step 4
}
},
"assert": {
// budgetsPath loads the LightWallet caps above. These fail the run
// when image bytes or image count exceed budget.json.
"budgetsPath": "./budget.json",
"assertions": {
// Outcome metric: fail if lab LCP exceeds 2.5 s (value in ms).
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
// Nudge, don't block, on generic total weight — the budget already
// hard-caps images; this catches slow creep as a warning.
"total-byte-weight": ["warn", { "maxNumericValue": 1638400 }],
// Flag images served larger than their display size.
"uses-optimized-images": ["warn", { "minScore": 0.9 }]
}
},
"upload": {
// Free trend storage; swap for "lh" + serverBaseUrl for a self-hosted
// LHCI server that keeps history and renders diffs across commits.
"target": "temporary-public-storage"
}
}
}
Step 4 — Add the GitHub Actions job
# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI
on:
pull_request: # gate every PR before merge
branches: [main]
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build # produce ./dist that staticDistDir serves
# Pin Chrome to an exact build. browser-actions/setup-chrome lets you
# request a fixed version so a Chrome auto-update cannot shift timings
# and manufacture a phantom regression between two identical commits.
- uses: browser-actions/setup-chrome@v1
id: setup-chrome
with:
chrome-version: 1250 # pin — do NOT use "stable"
- name: Run Lighthouse CI
run: npx lhci autorun
env:
# Point lighthouserc.json's chromePath at the pinned binary.
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
# Lets the LHCI GitHub app annotate the PR with pass/fail status.
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
Verification steps
1. Confirm the budget loads and reports
# Run locally. The assertion output lists every failing audit with the
# actual vs expected value. A loaded budget shows "resource-summary" rows.
npx lhci autorun 2>&1 | grep -iE 'resource-summary|largest-contentful|budget'
A correctly loaded budget prints lines like resource-summary.image.size failure with the measured KB when over budget, and nothing (a pass) when under.
2. Prove the gate actually fails
The gate is worthless until you have seen it turn red on purpose. Drop an oversized asset into a page and open a throwaway PR:
# Add a deliberately huge image to blow the 500 KB image budget.
cp fixtures/uncompressed-8mb.png dist/products/hero.png
git checkout -b test/break-image-budget
git commit -am "test: oversized hero to prove the gate fails" && git push
The Lighthouse CI check on that PR must go red, and the run log must name resource-summary.image.size (or image count) as the failing assertion. If it passes, your budget units are wrong (see the kilobytes warning) or the audited URL does not include the page you changed.
3. Read the failing check
Expected CI failure excerpt:
✘ resource-summary.image.size failure for min-score assertion
expected: <=512000
found: 8617432
all values: 8617432, 8617011, 8620145
The all values line reflects numberOfRuns: 3 — three medians, confirming the gate is averaging rather than trusting a single noisy run.
Common mistakes and fixes
1. Using resourceSummary assertions instead of a performance-budget
Anti-pattern: trying to cap image bytes by writing an assert rule against the resource-summary audit’s raw numeric value.
Effect: resource-summary returns a structured multi-item object, not a single number, so maxNumericValue has nothing clean to compare against and the check either errors or never trips.
Fix: cap image bytes and counts through budget.json (resourceSizes / resourceCounts) referenced by budgetsPath. Reserve assertions for single-value audits like largest-contentful-paint. Budgets for grouped resources, assertions for scalar metrics.
2. Byte/kilobyte unit confusion
Anti-pattern: { "resourceType": "image", "budget": 500000 } intending 500 KB.
Effect: the budget is interpreted as 500,000 KB (≈ 500 MB) and can never fail.
Fix: LightWallet budget values are kilobytes. 500 KB is 500. Verify by intentionally breaching it (Verification step 2).
3. Flaky thresholds from single runs
Anti-pattern: numberOfRuns: 1 with an LCP assertion set 1–2% above the current value.
Effect: normal run-to-run variance pushes LCP over the line on unrelated commits, and the team starts merging past a red check — the gate is now noise.
Fix: set numberOfRuns: 3 (or 5) so LHCI reports medians, and set thresholds with 10–15% headroom over the baseline. The goal is to catch a real regression, not to police jitter. This is the same median-of-N discipline used in WebPageTest filmstrip diff automation.
4. Not pinning Chrome
Anti-pattern: letting CI use whatever stable Chrome the runner image ships that week.
Effect: a Chrome update changes rendering/scheduling timings, LCP shifts a few hundred milliseconds, and a commit that touched only copy shows a “regression.” Trust in the gate erodes.
Fix: pin an exact Chrome version in CI and pass its path via chromePath / CHROME_PATH, as in Step 4. Bump the pin deliberately, in its own PR, so any timing shift is attributable.
5. Auditing a URL that isn’t the changed page
Anti-pattern: the budget only lists /index.html, but the regression landed on /products.
Effect: green check, shipped regression.
Fix: enumerate every high-traffic template in collect.url, and give byte-heavy pages their own budget.json object with a path-specific ceiling. Budget the pages that actually carry media.
Tradeoff: every URL and every extra run multiplies CI minutes. Three runs across five URLs is fifteen Lighthouse executions per PR. Keep the audited set to your highest-traffic, most media-heavy templates rather than the whole site, and reserve exhaustive crawls for a nightly job.
Related
- Monitoring & Regression for Media Delivery — the full lab-and-field loop this budget gate plugs into
- WebPageTest Filmstrip Diff Automation for LCP — the render-timeline gate that catches regressions a byte budget misses
- Tracking LCP Field Data with the CrUX API — confirming a passing lab budget in real-user field data
- Using fetchpriority to optimize critical media — the LCP-image lever to reach for when the LCP assertion fails
- AVIF vs WebP Compression Benchmarks — format choices that keep total image bytes under budget