Lightscore

How to run Lighthouse in a GitHub Action

Add treosh/lighthouse-ci-action to a workflow and give it a URL. That's the job. It takes five minutes, and it's the part every guide on this query already covers.

Then change one input. The action does one run per URL by default, and that single run is what your build gets graded on. We ran the same page five times in one job on a GitHub-hosted runner and scored 32, 34, 34, 59 and 57.

Here's the workflow, and then what a GitHub-hosted runner does to the numbers it gives you.

The workflow

# .github/workflows/lighthouse.yml
name: lighthouse
on: pull_request
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@@v6
      - uses: treosh/lighthouse-ci-action@@v12
        with:
          urls: |
            https://staging.example.com/
          runs: 5
          budgetPath: ./budget.json
          uploadArtifacts: true

Four inputs matter and three of them default to something you probably don't want:

Input Default Set it to
runs 1 5 — one run is a coin toss
uploadArtifacts false true, or the reports die with the job
temporaryPublicStorage false true only on a public repository
budgetPath none a budget.json, if you want a hard gate

The budget file is the gate that survives a noisy runner, because it counts bytes rather than milliseconds. Sizes are in kilobytes:

[
  {
    "path": "/*",
    "resourceSizes": [
      { "resourceType": "script", "budget": 150 },
      { "resourceType": "total", "budget": 500 }
    ]
  }
]

For a static build, drop urls and point configPath at a lighthouserc.json. The action then serves the directory itself. The nesting matters — a top-level key fails the job:

{ "ci": { "collect": { "staticDistDir": "./dist" } } }

Which runner do you actually get?

You get a different machine on a private repository than on a public one, and the difference is bigger than the specification sheet says.

We ran the identical workflow in a public repository and a private repository, 10 seconds apart, on 13 August 2026. Both asked for ubuntu-latest:

Repository vCPU RAM Chromium it used
Public 4 16 GB 151
Private 2 8 GB 150

The core counts match GitHub's published specification. The last column is the one that catches people out. Two runners, one action, one pinned version — and different Chromium builds on the same day. The action uses whatever browser the runner image ships, so the browser is not yours to pin.

Google's own notes on Lighthouse variability set a floor of 2 dedicated cores and recommend 4. A private repository therefore sits exactly on the minimum. Your code has nothing to do with it. The variable is the machine that grades your page.

How much does the runner move the score?

It moved it more than the page did, and the direction surprised me.

We pointed both jobs at vercel.com — a real, heavy, JavaScript-driven page — and took five runs each. The private 2-vCPU runner scored 32, 32, 33, 32, 33. The public 4-vCPU runner, the better machine, scored 32, 34, 34, 59, 57.

Performance score Run within the job 0 10 20 30 40 50 60 0 1 2 3 4 5 Public runner (4 vCPU) Private runner (2 vCPU)
Five consecutive Lighthouse runs of vercel.com in each of two jobs, 13 August 2026. The smaller runner was the steadier one.

Run 3 to run 4 is a 25-point step, mid-job, on a single machine. The page barely changed: about 2.13 MB each time, within 20 KB, across 127 to 129 requests. Server response time was 13 ms on all five. The work is what moved. Main-thread workTotal time the browser’s single UI thread spent parsing, compiling and running code. fell from 8,780 ms on the first run to 4,560 ms on the last. The unthrottled LCPTime until the largest thing in view (hero image, headline) has painted. the browser observed fell with it, from 2,368 ms to 322 ms.

I can't tell you why from the report, and that's the honest finding. Google's variability table lists page nondeterminism as not mitigated by simulated throttling, by DevTools throttling, or by no throttling at all. Client hardware variability is partly mitigated under simulated throttling, which is the default, and not at all under the others. A Lighthouse report can't separate the two, so neither can I.

So a faster runner doesn't buy you a stabler number. Five runs and a median do.

The first run is the expensive one

Across all four of our five-run sequences, run 1 was never the best. Three times it was outright the worst, and on the fourth it tied.

We also measured lightscore.dev, which is a deliberately dull page: one origin, almost no JavaScript. It scored 100 on seven of the ten runs. The exceptions were 95 and 90, both on run 1 of their job, plus a 99 on run 3 of the private job.

The cost sits in the same place both times:

Runner Main-thread work, run 1 Median of runs 2–5
Public (4 vCPU) 1,067 ms 158 ms
Private (2 vCPU) 1,951 ms 235 ms

That's about seven and eight times the work, on a page nobody touched between runs.

Now put that next to runs: 1. The default takes one sample, and on both of our jobs it took the expensive one.

what’s this

Total Blocking Time. The sum of "blocking" time across long JavaScript tasks between first paint and interactivity. It’s the lab stand-in for responsiveness — a high TBT means the page looks ready but ignores you.

Two defaults that quietly break the job

The status check lands on a commit nobody sees

Lighthouse CI can post a GitHub status check that links to the report. You get it by putting an LHCI_GITHUB_APP_TOKEN from the official Lighthouse CI GitHub app into the job environment.

The token on its own does nothing, and the failure is silent. The status check is posted by the upload step, so you also need an upload target: either temporaryPublicStorage: true or a serverBaseUrl with a serverToken. Set the token next to uploadArtifacts alone and no check appears, with no error to tell you why.

Then there's the commit it attaches to:

- uses: actions/checkout@@v6
  with:
    ref: ${{ github.event.pull_request.head.sha }}
    fetch-depth: 20

On a pull_request event, GitHub does not run your branch. It runs a merge commit of your branch into the base, and GITHUB_SHA points at that merge commit. Check out the default and the status check attaches to a SHA on refs/pull/N/merge. That is not the commit the pull request shows you.

fetch-depth is a separate problem. Lighthouse CI walks the git history to find the ancestor commit it should compare against, and the default shallow checkout gives it nothing to walk. Its troubleshooting guide pairs the deeper checkout with an explicit git fetch of the base branch, so budget for both.

The action itself posts no pull-request comment. If you want one, format the JSON yourself and pipe it into a sticky-comment action. That gap is one reason people reach for a wrapper action instead.

Your reports evaporate

Both storage inputs default to false. Set neither and you still get the pass or fail annotations in the log, but every report is deleted with the runner.

uploadArtifacts: true is the safe one. It writes the full Lighthouse JSON and HTML into the job's artifacts, which respect your repository's visibility.

temporaryPublicStorage: true is the one to think about. Its own documentation is clear: the reports go to a public URL on Google Cloud, and they're deleted 7 days after upload. On a public repository that's fine and convenient. On a private one, you have just published your staging site's full DOM, script inventory and screenshots to anyone with the link.

What URL should the job point at?

This is the decision that actually determines whether the job is worth the minutes, and it gets less attention than the YAML.

Target What it catches What it hides
staticDistDir bundle growth, before merge CDN, real cache, third parties
Preview deploy the built app on real infrastructure production cache state, real traffic
Production what visitors get it's too late to block anything

A preview deploy is the usual answer for a pull_request job, with one trap. The URL must be live before Lighthouse asks for it. Add a wait step, or the job measures a 404 and reports it as a fast page.

What the job can't tell you

You are not on current Lighthouse. Our runs reported Lighthouse 12.6.1. The action depends on @lhci/cli, and its latest release, 0.15.1, pins lighthouse at exactly 12.6.1. Lighthouse itself is on 13.4.1.

The action's README still says each URL is "audited using the latest version of Lighthouse and Chrome preinstalled on the environment". Half of that is now wrong. The Chrome is the preinstalled one, but the Lighthouse is not the latest. Scoring curves and metric weights change between versions, so record the version next to every score you keep.

One runner is one machine in one place. The runner sits wherever GitHub put it. It tells you what your page costs from there, on a shared CPU, at that moment.

A green build is not a fast site. This job measures a build or a preview, and neither has your production cache, your real third-party tags, or your visitors' distance to your origin.

The split I'd suggest: keep the GitHub Action as the pre-merge gate on bytes. A budget there catches a regression the moment someone adds a dependency. Then measure the deployed URL separately, after the deploy, from the places your users are. How to run Lighthouse in CI covers the gate itself, including the aggregation default that picks which of your five runs gets compared.

Lightscore does the second half. You POST a URL and pick regions. Back come the scores, plus the complete Lighthouse JSON and HTML. We're on Lighthouse 12 too. The difference is that both versions are pinned, and we print them at the top of the homepage. You always know what measured your page.

What we can't reach is ./dist, or a preview URL behind authentication. A worker in Frankfurt has no route to your build agent. For that half the GitHub Action is the right tool, and we're not a replacement for it.

Common questions