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.

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

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.