DEV Community

SwiftKit
SwiftKit

Posted on

Run Lighthouse on hundreds of pages from Node (no PageSpeed API key), and why the scores move

PageSpeed Insights is great for one URL. For 300 product pages, or "all our landing pages after every release", you want Lighthouse in a script. The good news: Lighthouse is an npm package, and you don't need an API key to run it yourself.

The minimal script

import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';

const chrome = await chromeLauncher.launch({
  chromeFlags: ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage'],
});
try {
  const { lhr, report } = await lighthouse('https://www.wikipedia.org', {
    port: chrome.port,
    output: ['json', 'html'],
    onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
    logLevel: 'error',
  });
  console.log(Math.round(lhr.categories.performance.score * 100));
  // report[1] is the full HTML report: save it, it's what people actually read.
} finally {
  await chrome.kill();
}
Enter fullscreen mode Exit fullscreen mode

For the desktop preset, pass Lighthouse's own config as the third argument:

const desktop = (await import('lighthouse/core/config/desktop-config.js')).default;
await lighthouse(url, flags, desktop);
Enter fullscreen mode Exit fullscreen mode

Two small gotchas I hit:

  • In chrome-launcher 1.x, kill() isn't guaranteed to return a promise, so chrome.kill().catch(...) can throw. Wrap it in try/catch instead.
  • If you run in Docker with Playwright's image, point chrome-launcher at Playwright's browser: chromePath: chromium.executablePath().

What to keep from the result

The lhr object is huge. For a bulk table, this is what I keep per page:

const score = (c) => Math.round(lhr.categories[c].score * 100);
const ms = (id) => Math.round(lhr.audits[id].numericValue);
const row = {
  performance: score('performance'),
  seo: score('seo'),
  lcpMs: ms('largest-contentful-paint'),
  tbtMs: ms('total-blocking-time'),
  cls: lhr.audits['cumulative-layout-shift'].numericValue,
  // Top fixes, ranked by time saved:
  opportunities: Object.values(lhr.audits)
    .filter((a) => a.details?.type === 'opportunity' && a.details.overallSavingsMs >= 100)
    .sort((a, b) => b.details.overallSavingsMs - a.details.overallSavingsMs)
    .slice(0, 8)
    .map((a) => ({ title: a.title, savingsMs: Math.round(a.details.overallSavingsMs) })),
};
Enter fullscreen mode Exit fullscreen mode

Also check lhr.runtimeError before reading anything. A page that failed to load still returns an lhr, just with garbage scores.

Run audits one at a time

It's tempting to audit 10 pages in parallel. Don't: Lighthouse simulates a slow device and measures CPU work, so parallel audits on one machine slow each other down and drag every performance score down. Run them one after another, each with a fresh Chrome. It's slower, but the numbers mean something.

Why the scores move

I ran the same pages on my laptop and on a 2 GB cloud container:

Page Laptop (mobile) Cloud (mobile)
wikipedia.org performance 98 92
wikipedia.org LCP 1,379 ms 1,497 ms

A heavier marketing site scored 25 for performance in the cloud, with an LCP over 9 seconds. Same code, same day.

Lab scores depend on the machine's CPU, and the mobile preset multiplies that with CPU throttling. So:

  • Compare pages within the same run, on the same machine, not across tools.
  • PageSpeed Insights also shows real-user data (CrUX) when Google has enough traffic. That's a different measurement, and often the one that matters for SEO.
  • Track trends (same pages, same setup, weekly) rather than single numbers.

The packaged version

If you'd rather not run Chrome fleets yourself, I put this on Apify as Lighthouse Audit: paste URLs, pick mobile, desktop or both, and get scores, Core Web Vitals, top fixes, failed SEO/accessibility checks and the full HTML report per page. It runs one audit at a time for the reasons above. Failed pages are free.

How do you track performance across many pages today? I'm curious whether people use CrUX data, lab runs, or both.


Written with AI assistance; the code and numbers were tested before publishing.

Top comments (3)

Collapse
 
beusebiu profile image
Eusebiu Balan •

Worth keeping lhr.environment.benchmarkIndex in that table too. Lighthouse measures how fast the machine is before each run, so when a weekly score drops you can tell a slower container from a slower page.

Their variability notes also suggest a few runs per URL and keeping the median, which smooths out the one bad run on a busy box.

Collapse
 
swiftkit_dev profile image
SwiftKit •

Thanks, that's a great tip. I've added both to the tool: every result now includes benchmarkIndex, and there's a "runs per page" option (up to 5) that keeps the run with the median performance score and lists all the scores plus each run's benchmarkIndex. On a quick test the three runs scored 97, 100 and 100, with benchmark indexes of 3963, 3834 and 4115, which shows how much the machine itself moves between runs.

Collapse
 
beusebiu profile image
Eusebiu Balan •

That was quick. 3834 to 4115 on one machine is about a 7% swing, and the same page went 97 to 100 across those runs. So a 3 point drop in a weekly report can be just the box.