DEV Community

Cover image for LightAudit Score is free. Here's the Lighthouse problem it solves.
Simon
Simon

Posted on

LightAudit Score is free. Here's the Lighthouse problem it solves.

Here it is, up front: LightAudit Score — a Lighthouse console that runs on your own machine, audits the localhost and staging URLs hosted tools can't reach, and costs nothing. Apache-2.0, no account, no paid tier, no third audit that asks for a card.

git clone https://github.com/mysleekdesigns/light-audit-score-app.git
cd light-audit-score-app
npm install
npm start
Enter fullscreen mode Exit fullscreen mode

Node.js 24+ and Chrome. That's the whole pitch.

The rest of this post is the more useful half: why it needed to exist. Two facts about Lighthouse that took me far too long to learn, and that are worth knowing whether or not you ever run my tool:

  1. Run Lighthouse twice on the same page, on the same machine, with the same settings, and you get two different Performance scores — usually about 5 points apart.
  2. PageSpeed Insights cannot audit localhost:3000, and never will be able to.

Both have specific, mechanical causes. Neither is a bug. And once you know what's actually going on, both have fixes that take about a paragraph to explain.


TL;DR — Total Blocking Time is CPU-sensitive and worth ~30% of the Performance score, so the score inherits your machine's mood. Fix it with median-of-N, one audit per process, and concurrency 1. Separately: Lighthouse's default 4× CPU throttle is tuned for a high-end desktop, so on an Apple Silicon Mac it under-throttles and your score reads optimistically. And PSI is a hosted service that fetches your URL from Google's network, which is why localhost is permanently out of reach.

Contents


Part 1: The variance is CPU, not the page

Lighthouse's Performance score is a weighted mean of five metrics. The one that moves is Total Blocking Time — roughly 30% of the score, and a direct measurement of how long the main thread sat blocked.

Main-thread blocking depends on how fast your CPU was going during that particular run: what else your machine was doing, whether it was thermally throttling, how many other headless Chrome instances were competing for cores.

So the score measures your page and your laptop's mood, then reports one number for both.

Running it again doesn't help

The instinct is to re-run and keep the better number. That isn't a measurement, it's selection bias with extra steps. What you want is the median:

Run 1:  78   <-- machine was busy
Run 2:  91   <-- clean run
Run 3:  86   <-- representative
                 ^ report this one
Enter fullscreen mode Exit fullscreen mode

Lighthouse ships the function for this. computeMedianRun takes a set of runs and picks the representative one — and crucially it picks a whole run, not a per-metric median, so the numbers you report stay consistent with each other and with a real trace.

Three runs is the usual sweet spot. Fewer is faster and noisier; more is steadier and slower.

Three things that quietly poison the median

Taking three runs buys you nothing if the three runs contaminate each other.

1. Warm cache. If run 2 reuses run 1's Chrome profile, it's measuring a cached page. Every run needs its own fresh headless Chrome with a unique temporary --user-data-dir, torn down afterwards even when the run errors.

2. Lighthouse's global state. This one is genuinely surprising. Lighthouse stores its lh:runner:* performance marks in process-global state, so two concurrent in-process lighthouse() calls corrupt each other's timings — silently, with no error. Any batch runner needs one forked process per job, not just one Chrome per job.

3. Concurrency, via a mechanism most people have backwards. Lighthouse's default "simulated" throttling doesn't throttle anything during the run. It loads the page once, unthrottled, then estimates throttled metrics from that single trace (the Lantern model).

The entire estimate therefore rests on one unthrottled trace. If three headless Chromes are fighting for CPU while that trace is captured, measured TBT and LCP inflate and the Performance score deflates. Parallelism here doesn't just add noise — it biases the score downward.

The counter-intuitive part
Accessibility, SEO and Best Practices don't depend on CPU throttling at all, so they're unaffected by concurrency. Parallelise those freely.

It's specifically Performance that needs to run alone. So the right design isn't "concurrency 1 for everything" — it's "effective concurrency 1 whenever Performance is in scope, full throughput otherwise."



Part 2: The 4× throttle is relative to your machine

This is the one that cost me a weekend, and I've never seen it stated plainly in the docs.

Lighthouse applies a 4× CPU slowdown multiplier by default, and everyone reads that as "simulates a mid-tier phone." It doesn't. Here's the actual relationship:

                    benchmarkIndex
                    (Lighthouse's "CPU/Memory Power" score)

  mid-tier mobile   ~440     +-- 4x is calibrated so that a
  high-end mobile   ~875     |   HIGH-END DESKTOP lands on
  high-end desktop  ~1750  <-+   the mid-tier mobile target
  Apple Silicon Mac ~4000  <---- you are here, and 4x is not enough
Enter fullscreen mode Exit fullscreen mode

The 4× default is tuned so a machine at benchmarkIndex ≈ 1750 — a high-end desktop — approximates mid-tier mobile. It's a relative multiplier anchored to an assumed host.

Your M-series Mac benchmarks around 4000. Apply 4× and you're still roughly twice as fast as the target you think you're simulating. Your Performance score is optimistic. Your CI machine's is probably pessimistic. Neither of you is wrong about your own number, which is exactly what makes the disagreement so hard to debug.

This is why the same site scores differently on two developers' machines with identical settings — "works on my machine," with a number attached.

Closing the gap

Every Lighthouse run records the host's benchmarkIndex; it's in the JSON under environment. Once you have it, the multiplier that actually targets mid-tier mobile from your host is:

cpuSlowdownMultiplier = Math.round(benchmarkIndex / 437.5)
Enter fullscreen mode Exit fullscreen mode

That anchor reproduces Lighthouse's own bracket table: high-end desktop (1750) → 4×, high-end mobile (875) → 2×, mid-tier mobile (437.5) → 1×. On a 4000-point Mac it gives you .

When you want 4× back
There's exactly one case for keeping Lighthouse's raw 4× on a fast machine: reconciling against the Chrome DevTools Lighthouse panel, which uses simulated throttling at a constant 4× by default.

To make a local number match what the panel shows on the same machine, use mobile + simulated + 1 run + concurrency 1 + Lighthouse's own 4×. That's a comparability setting, not an accuracy one — you're deliberately reproducing the panel's bias so the two line up.

Calibrated multiplier when you want the most representative mid-tier-mobile number. Raw 4× when you want to match DevTools. Different questions.


Simulated vs applied, briefly

  • Simulated (default) — one unthrottled load, then Lantern estimates the throttled metrics. Fast, low-variance, and what the DevTools panel and PageSpeed Insights both do. Pick this for comparability.
  • Applied (devtools) — real CPU and network throttling applied to Chrome during the run. Slower, noisier, closer to how a throttled device actually behaves. Pick this when you want measured behaviour rather than an estimate.

Most people should stay on simulated and calibrate the multiplier. Switching to applied to "be more realistic" mostly buys variance.


Part 3: Why PageSpeed Insights can't reach localhost

This one is architectural, with no workaround, so it's worth being precise about.

PageSpeed Insights is a hosted service. You hand it a URL, and Google's infrastructure fetches that URL from Google's own network:

   your browser          Google's network            your app
  +------------+        +-----------------+        +----------+
  |  PSI form  | -----> |  PSI fetches    | -----> | must be  |
  |            |        |  the URL itself | --X--> | publicly |
  +------------+        +-----------------+        | routable |
                                                   +----------+
                              localhost:3000 resolves to
                              GOOGLE'S loopback, not yours
Enter fullscreen mode Exit fullscreen mode

localhost:3000 is unreachable by definition — from Google's side, localhost is Google's own machine. Same for a staging box behind a VPN, same for the intranet app with no public DNS.

This isn't a limitation to route around with a tunnel. It's what "hosted" means.

What you get instead, and what you give up

Running Lighthouse locally — DevTools, the CLI, or anything wrapping the npm package — uses the same engine PSI runs, currently v13. A local score is a legitimate lab measurement, not a lesser approximation.

What you give up is field data. PSI returns real-user Core Web Vitals from the Chrome UX Report: LCP, INP and CLS at the 75th percentile, from actual visitors. No local run can produce that, because your machine isn't your users.

The honest split:

Lab (local Lighthouse) Field (CrUX via PSI)
What it measures one controlled run real visitors, 28-day window
Works on localhost Yes No
Works pre-launch Yes No (needs traffic)
Catches a regression today Yes No (lags weeks)
Reflects real users No Yes

You want both, at different times. Lab while you build, field once you ship.

One gotcha if you're calling the PSI API

Keyless PSI calls are now capped at a 0 daily quota. You get an instant HTTP 429, which reads like rate limiting but actually means "you have no quota at all."

So you need an API key. It's free — 25,000 requests a day — and it's two steps in the Google Cloud Console: enable the PageSpeed Insights API on your project, then create an API key on that same project. The second step is the one people skip, and a key without the API enabled on its project fails in a way that looks like an auth problem.


Part 4: What the tool actually does

Everything above is implementable yourself. Median-of-N is a loop, process isolation is fork(), calibration is one division. LightAudit Score is just all of it already wired together:

  • Median-of-N with a fresh isolated Chrome per run and one forked process per job.
  • Calibration from your host's benchmarkIndex, plus an environment badge on every result showing the host power, throttling method and multiplier that produced it.
  • A drift warning when the machine moved the score rather than the page — host-power drift, CPU contention, or concurrency contention.
  • PSI and CrUX field data for live URLs from the same console, so the lab/field split above lands in one table instead of two tools with two histories.
  • Whole-site batches from a pasted list or a robots-aware crawl, persisted history in local SQLite, trend charts, and a two-run diff.

The part I haven't seen elsewhere: an MCP server with memory

It also ships an MCP server, so a coding agent can audit the page you just changed:

Tool What it does
audit_url Runs a real Lighthouse audit, returns scores + Core Web Vitals and the runId it was archived under
get_history Recent runs for a URL — this is what finds you a baseline
compare_runs Audit-level diff between two runs: what moved, and what that did to the score
check_budget Asserts a persisted run against a bar, returns structured pass/fail

Other Lighthouse MCP servers are stateless single-page wrappers: they audit, they answer, they forget. This one reads the same local archive the app writes, including runs you did by hand in the UI. So an agent can answer "did my change make this worse?" against a baseline that already exists — instead of auditing a page it has no history for and calling 86 a good number.

The one deliberate divergence
audit_url defaults to runs: 1, while the app defaults to 3.

An agent blocks on the call for as long as the audit takes, and three sequential Lighthouse runs is a long time to sit waiting in a conversation. A single run is the sane default in that context — pass runs: 3 when you want a number directly comparable to the app's.

A real tradeoff, and better known than discovered.


The AI analysis runs on whatever you already have: a Claude Code login, a local Ollama model, or any OpenAI-compatible endpoint. Fixes carry citations only when you connect a research MCP server — CrawlForge is the documented option, handed only search-and-read tools so an analysis costs a handful of credits rather than a crawl. With nothing connected, the analysis is badged as having done no web research rather than quietly inventing a source, which for AI-generated performance advice seems like the minimum bar.


The checklist

If you take nothing else from this:

  • [ ] Take a median of 3+ runs. One run is an anecdote.
  • [ ] Fresh Chrome profile per run. A warm cache is a different measurement.
  • [ ] One audit per process. Lighthouse's lh:runner:* marks are process-global and corrupt concurrent in-process runs.
  • [ ] Concurrency 1 when Performance is in scope. Simulated throttling estimates from an unthrottled trace, and contention during that trace deflates the score.
  • [ ] Check your benchmarkIndex and calibrate. 4× targets mid-tier mobile from a high-end desktop. On an M-series Mac you want closer to 9×.
  • [ ] Record the environment with the score. Host power, throttling method, multiplier. A score without them isn't comparable to anything — including your own score from last week.
  • [ ] Don't wait for PSI to test. It can't see localhost, and the regression you want to catch is the one you just wrote.

The general principle: a Lighthouse score is a measurement, and a measurement without its conditions recorded isn't one. Most of the work of trusting these numbers is just writing down what the machine was doing at the time.


What's the biggest gap you've seen between the same page's score on two different machines? I'd be curious whether the ~2× I keep hitting between an M-series Mac and CI is typical.

Clone LightAudit Score on GitHub

Top comments (0)