Run Lighthouse on many pages at once

Lighthouse scores for a whole list of URLs: what the PageSpeed Insights API allows, a tested script that runs the Lighthouse CLI several times per page and keeps the median, and when a hosted run saves you the work.

Commands and script tested on 28 October 2026 with Lighthouse 13.5.0.

Checking one page in Lighthouse takes a minute. Checking 40 product pages, or the home pages of 20 client sites, every month, is a different job. You need a loop, a way to deal with noisy scores, and somewhere to put the results. This guide covers the free ways first: the PageSpeed Insights API and the Lighthouse command line. Then it covers when a hosted tool is the better choice.

Disclosure: Quarrywise, the publisher of this site, makes and sells the website health check described near the end. The free methods need nothing from us.

First: one run is not enough

Lighthouse performance scores move between runs, even when nothing on the page changed. The Lighthouse team's own note on variability lists causes such as the network, the web server, your machine's load and the browser itself. It says the median score of 5 runs is twice as stable as 1 run, and it advises against drawing conclusions from single results.

So for each page, run Lighthouse several times (3 is a fair minimum, 5 is better) and keep the median. Everything below does that.

Also keep in mind that Lighthouse gives lab data: one simulated load on one machine. Real-visitor data (field data, from the Chrome UX Report) is a different thing. PageSpeed Insights shows both when Google has field data for the page; the Lighthouse CLI shows only lab data.

Free option 1: PageSpeed Insights

The PageSpeed Insights website runs Lighthouse on Google's servers, one URL at a time. It is the quickest way to check a handful of pages, and the only free option here that also shows field data.

For a list of URLs, there is the PageSpeed Insights API:

curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com/&strategy=mobile&category=performance&category=seo"

Google's docs say the API "can be used with or without an API key, although a key is recommended for frequent, automated queries". Without a key you share a quota with everyone else who calls it without a key. When we ran the request above on 28 October 2026, it answered:

HTTP 429
"Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service
'pagespeedonline.googleapis.com' for consumer 'project_number:...'"

That is the common PageSpeed Insights 429 error. The fix is your own key:

  1. Create a project in Google Cloud Console and enable the PageSpeed Insights API.
  2. Create an API key under Credentials. It is free.
  3. Add &key=YOUR_KEY to each request.

Your key then has a quota of its own, shown on the Quotas page of your Cloud project. It is often reported as 25,000 queries a day. We did not create a key for this guide, so check the number for your own project. Two more things to know:

  • One request is one Lighthouse run. For a median, call each URL several times.
  • Results are cached. Google's FAQ says PageSpeed Insights caches results for a short period, so calls a few seconds apart may not give you independent runs.

Free option 2: the Lighthouse CLI on your own machine

With Node.js 22.19 or newer (the minimum for Lighthouse 13.5.0) and Google Chrome installed, you can run Lighthouse yourself, with no key and no quota. One page:

npx --yes lighthouse@13.5.0 https://example.com/ --only-categories=performance,seo --output=json --output-path=./example.json --chrome-flags="--headless=new" --quiet

This wrote example.json with a mobile performance score of 100, an SEO score of 80 and a Largest Contentful Paint of about 900 ms for example.com. The default is a simulated mid-range phone; add --preset=desktop for desktop.

Tested script: many URLs, several runs, one CSV

This Python script (standard library only) reads URLs from a text file, runs Lighthouse 3 times per URL, and writes the medians to a CSV file. Each run is a separate Lighthouse process. The full JSON reports are kept in a reports folder. Each page's row is written to the CSV as soon as that page is done, so a crash or a stop halfway keeps the rows you already have. A run that times out, fails, or comes back without a score is skipped and not counted.

The script calls the Lighthouse CLI through node directly, not through npx. On Windows, npx is a .cmd file that runs through cmd.exe, which splits a URL such as https://example.com/?a=1&b=2 at the &. Called through node, each URL reaches Lighthouse in one piece.

To use it:

  1. Install Node.js 22.19 or newer (Lighthouse 13.5.0 needs it), Google Chrome and Python 3.
  2. In an empty folder, run npm init -y and then npm install lighthouse@13.5.0. The first command gives the folder its own package.json. Without it, npm installs into the nearest parent folder that has one, and the script cannot find Lighthouse.
  3. Save the script below as bulk_lighthouse.py in that folder.
  4. Put one URL per line in urls.txt in the same folder.
  5. Run python bulk_lighthouse.py urls.txt.
import csv, json, pathlib, re, shutil, statistics, subprocess, sys, time

URL_FILE = sys.argv[1] if len(sys.argv) > 1 else "urls.txt"
RUNS = 3                      # runs per page; the median of 3 is much steadier than 1 run
DEVICE = "mobile"             # or "desktop"
TIMEOUT = 180                 # seconds before one run is given up
# Lighthouse installed in this folder with: npm init -y, then npm install lighthouse@13.5.0
# Node runs the Lighthouse CLI directly, without npx or a shell, so every URL reaches
# Lighthouse as one argument, also on Windows and also when it contains "&".
LIGHTHOUSE = [shutil.which("node"),
              str(pathlib.Path("node_modules/lighthouse/cli/index.js").resolve())]
if not LIGHTHOUSE[0] or not pathlib.Path(LIGHTHOUSE[1]).exists():
    sys.exit("Node.js or node_modules/lighthouse not found in this folder. "
             "Run: npm init -y, then npm install lighthouse@13.5.0")
OUT = pathlib.Path("reports")
OUT.mkdir(exist_ok=True)
FIELDS = ["url", "runs_ok", "performance_median", "performance_min", "performance_max",
          "seo_median", "lcp_ms_median", "tbt_ms_median", "cls_median"]


def read_run(path):
    """Scores of one report, or (None, reason) when the run has no usable result."""
    report = json.loads(path.read_text(encoding="utf-8"))  # reports are UTF-8
    if report.get("runtimeError"):
        return None, report["runtimeError"].get("code")
    categories, audits = report.get("categories", {}), report.get("audits", {})
    values = (categories.get("performance", {}).get("score"),
              categories.get("seo", {}).get("score"),
              audits.get("largest-contentful-paint", {}).get("numericValue"),
              audits.get("total-blocking-time", {}).get("numericValue"),
              audits.get("cumulative-layout-shift", {}).get("numericValue"))
    if None in values:        # Lighthouse leaves a score empty when a metric fails
        return None, "a score or metric is missing"
    perf, seo, lcp, tbt, cls = values
    return (perf * 100, seo * 100, lcp, tbt, cls), None


urls = [u.strip() for u in open(URL_FILE, encoding="utf-8") if u.strip()]
csv_path = f"lighthouse-{DEVICE}.csv"

with open(csv_path, "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=FIELDS, restval="")
    writer.writeheader()
    for url in urls:
        slug = re.sub(r"[^a-z0-9]+", "-", url.lower()).strip("-")[:80]
        runs = []
        for i in range(1, RUNS + 1):
            path = OUT / f"{slug}-{DEVICE}-{i}.json"
            path.unlink(missing_ok=True)  # never read a report from an earlier batch
            cmd = LIGHTHOUSE + [url, "--output=json", f"--output-path={path}",
                                "--only-categories=performance,seo",
                                "--chrome-flags=--headless=new", "--quiet"]
            if DEVICE == "desktop":
                cmd.append("--preset=desktop")
            try:
                done = subprocess.run(cmd, timeout=TIMEOUT)
            except subprocess.TimeoutExpired:
                print(f"  run {i} timed out for {url}")
                continue
            finally:
                time.sleep(2)     # a short pause between page loads
            if done.returncode != 0 or not path.exists():
                print(f"  run {i} failed for {url}")
                continue
            try:
                values, problem = read_run(path)
            except (OSError, ValueError) as error:
                values, problem = None, f"unreadable report ({error})"
            if values is None:
                print(f"  run {i} skipped for {url}: {problem}")
                continue
            runs.append(values)
        row = {"url": url, "runs_ok": len(runs)}
        if runs:
            perf, seo, lcp, tbt, cls = zip(*runs)
            row.update({
                "performance_median": round(statistics.median(perf)),
                "performance_min": round(min(perf)),
                "performance_max": round(max(perf)),
                "seo_median": round(statistics.median(seo)),
                "lcp_ms_median": round(statistics.median(lcp)),
                "tbt_ms_median": round(statistics.median(tbt)),
                "cls_median": round(statistics.median(cls), 3),
            })
        writer.writerow(row)
        f.flush()                 # each finished page is on disk at once
        print(row)

print(f"{len(urls)} pages written to {csv_path}")

We ran it on four pages: example.com, the same page with ?a=1&b=2 added (to check the & on Windows), and two pages of our own site. Twelve runs took about two and three quarter minutes, and it wrote:

url,runs_ok,performance_median,performance_min,performance_max,seo_median,lcp_ms_median,tbt_ms_median,cls_median
https://example.com/,3,100,100,100,80,906,0,0
https://example.com/?a=1&b=2,3,100,100,100,80,908,0,0
https://quarrywise.com/,3,100,100,100,92,924,0,0
https://quarrywise.com/guides/,3,100,100,100,92,1225,0,0

Runs do fail now and then. In a test of the first version of this script (same loop, called through npx), one run ended with Lighthouse's NO_NAVSTART error ("Something went wrong with recording the trace over your page load"), and that page's row showed 2 under runs_ok. So always look at runs_ok. We also checked that a run that times out, or a report with an empty performance score, is skipped and the script carries on to the next page.

Notes for Windows

  • Do not call npx from a script with URLs that contain &. As explained above, cmd.exe cuts the URL at the &, Lighthouse audits the cut-off address, and the rest is read as a separate command. Call node with the Lighthouse CLI file, as the script does.
  • Read reports as UTF-8. Lighthouse writes UTF-8. On Windows, Python's default encoding is often not UTF-8, and reading example.json without encoding="utf-8" failed with a UnicodeDecodeError in our test.
  • EPERM errors. One Stack Overflow question reports that Lighthouse CI, run from Node on Windows, fails with an EPERM error on a temporary lighthouse.* folder whenever it runs Lighthouse consecutively, with multiple runs or against multiple URLs. The asker tried execSync and a different --user-data-dir without success, and the question has no answer yet. None of our test runs failed with EPERM, but that is all we can say: we do not know the cause or a fix.
  • Leftover temp folders. In our first test, three of ten runs left a lighthouse.* folder in %TEMP%, while the runs themselves succeeded. In the second test of twelve runs, none did. Check %TEMP% now and then and delete those folders.

Other free tools

Lighthouse CI runs Lighthouse in a CI pipeline, with several runs per URL and pass or fail assertions. Unlighthouse is an open-source tool that scans a whole site. We did not test either for this guide.

Two limits apply to every local run. Your scores depend on your machine and connection, so run on a quiet machine and compare results from the same machine only. And you have to keep Chrome, Node.js and the script working, and keep the results somewhere.

When a hosted tool makes sense

The free routes are enough for a few pages, or for a team that already runs CI. A hosted tool starts to pay off when:

  • you check many sites, for example for clients, and do not want a machine running Chrome for hours;
  • you want the same check every week, compared with last week;
  • you want more than scores: SEO problems with fixes, which third-party scripts cost the most, and the tech stack.

Our tool: Website Health Check on Apify

Our Website Health Check: Lighthouse, SEO, Tech Stack on the Apify Store runs Lighthouse itself, in the cloud, so you need no Google API key and no local Chrome.

  • Lists: up to 200 addresses per run. It can also take extra pages from each site's sitemap, up to 500 pages per site.
  • Medians: 1 to 5 Lighthouse runs per page and device, mobile, desktop or both, reported as the median with the lowest and highest value.
  • Budgets: limits such as performance 60 or LCP 3000 ms, with pass or fail per page.
  • Changes over time: with a watch ID, each run reports what changed since the previous one, and only counts a score change that is larger than normal run-to-run variation.
  • More than Lighthouse: SEO findings ranked by impact with a fix for each, the technologies a site runs on, redirects, and the cost of each third-party vendor's scripts.

An input for up to 10 pages of one site (your address plus pages from its sitemap), mobile, 3 runs each, with budgets:

{
    "urls": ["https://shop.example.com/"],
    "mode": "full",
    "maxPagesPerSite": 10,
    "lighthouseDevices": ["mobile"],
    "lighthouseRuns": 3,
    "lighthouseMaxPagesPerSite": 10,
    "budgets": { "performance": 60, "lcp_ms": 3000 }
}

Price when this guide was written: pay per event, USD 0.008 per page checked and USD 0.02 per completed Lighthouse run, plus Apify's small start fee per run (USD 0.0001 for a full-mode run with 2 GB of memory). The input form shows these prices in the description of the Pages per site field, and the Store page always shows the current price. Some examples:

  • the input above, when the site has 10 pages to check (10 pages, 30 Lighthouse runs): USD 0.08 + USD 0.60 = USD 0.68;
  • one page, 3 mobile and 3 desktop runs: USD 0.128;
  • the prefilled example (3 pages of our own site, 1 mobile Lighthouse run): about USD 0.044;
  • 100 pages in quick mode (SEO findings, tech stack, redirects, no Lighthouse): USD 0.80.

Pages that cannot be checked, an address that leads to a page already checked in the same run, and failed Lighthouse runs are free. The run stops at the maximum charge you set, never starts a Lighthouse run the remaining budget cannot pay for, and names the pages that got no Lighthouse run for that reason. When a run ends, its status line says what it did, for example "Done: 3 pages checked, 1 Lighthouse run. Full report in the REPORT record."

How long it takes: the prefilled example takes about 40 seconds in full mode. Each Lighthouse run takes roughly 10 to 30 seconds, plus the spacing between page loads of one site, so the input above (30 Lighthouse runs) can take roughly 8 to 18 minutes. In quick mode, runs of 3 to 10 pages took 8 to 25 seconds.

The limits, honestly:

  • It is lab data from Apify's cloud, not field data from your visitors.
  • At most 30 Lighthouse page loads per site per run (pages × devices × runs), at least 5 seconds apart. It runs one Lighthouse test at a time, so a large full audit takes a while: for several sites, raise the run timeout in the run options. A full-mode run called through the API's synchronous endpoint can take longer than the 300 seconds that call waits, so start those runs asynchronously and read the results when they finish.
  • Full mode needs 2 GB of memory (its default). With less than 1.5 GB the tool runs the quick checks only and says so.
  • It respects robots.txt and stops at challenge pages (Cloudflare, CAPTCHA), 401 and 403. A site behind a bot wall gets a free error row instead of a score.

See the website health check on the Apify Store

FAQ

Why does the PageSpeed Insights API return 429?

Without a key, you share one daily quota with every other keyless caller, and it can run out early in the day. Add your own free API key with &key=, and the quota is yours.

How many Lighthouse runs per page should I do?

At least 3, and 5 when you compare before and after a change. Use the median, and look at the spread between the lowest and highest score.

Why does my local score differ from PageSpeed Insights?

Both are lab tests, but on different machines, networks and sometimes Lighthouse versions. Compare results from the same tool and the same device only.

Can I get real-visitor (field) data this way?

Not from the Lighthouse CLI or from our tool: both give lab data. PageSpeed Insights and the Chrome UX Report show field data for pages with enough Chrome traffic.

Quarrywise is independent and isn't affiliated with or endorsed by Google, the Lighthouse project or Apify.

Sources