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();
}
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);
Two small gotchas I hit:
- In chrome-launcher 1.x,
kill()isn't guaranteed to return a promise, sochrome.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) })),
};
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)
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.
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'sbenchmarkIndex. 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.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.