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” — a ceiling that only stays livable if the pipeline is already emitting modern formats, which is why the numbers in the AVIF vs WebP compression benchmarks are the right input when you pick one.

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, and when that assertion is the one that goes red while the byte budget passes, the cause is usually discovery rather than weight — reach for fetchpriority and preload hints on the LCP image before touching the encoder.

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.

Lighthouse CI budget and assertion evaluation flow lhci autorun collects a Lighthouse run, then evaluates two checks in parallel: budget.json caps image bytes and image count via resource-summary; the assert block checks largest-contentful-paint. Either failing produces a non-zero exit that fails the CI check. lhci autorun collect in headless Chrome budget.json (LightWallet) resourceSizes: image ≤ 500 KB resourceCounts: image ≤ 15 via resource-summary audit assert block largest-contentful-paint ≤ 2500 total-byte-weight (warn) per-audit thresholds exit code any breach → ≠ 0 either check failing fails the CI job

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 }}

Wall-clock cost matters, because a gate developers wait on is a gate developers route around. On a warm ubuntu-latest runner the job above lands at roughly four and a half minutes, and the collect phase — two URLs at numberOfRuns: 3, so six full Lighthouse executions — is the only stage worth optimizing. Everything else is fixed overhead.

Where the Lighthouse CI pull-request job spends its time A horizontal timeline of seven CI stages against an elapsed-seconds axis. Checkout, npm ci, build, and pinned Chrome setup together take about 105 seconds; the lhci collect stage running six Lighthouse executions takes about 138 seconds and dominates; assert and upload finish in under 15 seconds. Lighthouse CI job on a pull request — elapsed time per stage actions/checkout@v4 6s npm ci 40s — restored from lockfile cache npm run build → ./dist 44s — what staticDistDir serves setup-chrome (pinned 1250) 15s — removes auto-update drift lhci collect 138s — 2 URLs × 3 runs 6 Lighthouse executions — the only stage worth tuning lhci assert 4s — budget.json + assertions lhci upload 11s — temporary-public-storage 0s 60s 120s 180s 240s Adding a fifth URL adds three more executions — roughly 70s. Budget the templates that carry media, not the whole site.

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. Plotted against the ceiling, the shape of a real regression is unmistakable: all three runs sit in the same place, far past the cap, with none of the spread that jitter produces.

Measured image bytes versus the budget ceiling Three Lighthouse runs measured 8,415, 8,415 and 8,418 kilobytes of image transfer against a 500 kilobyte image cap and a 1,600 kilobyte total-page cap. All three bars extend far past both dashed ceilings, and their near-identical lengths show the breach is a real regression rather than run-to-run noise. resource-summary.image.size — three runs against the 500 KB cap total-page cap 1,600 KB image cap 500 KB run 1 — median 8,415 KB run 2 8,415 KB run 3 8,418 KB a healthy build ≤ 500 KB — passes 0 2,000 KB 4,000 KB 6,000 KB 8,000 KB Expected ≤ 512,000 bytes; found 8,617,432 bytes — 16.8× the cap, and the spread across runs is 0.04%. Jitter looks like scattered bars near the line; a regression looks like this — three identical bars far past it.

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.