Why Run Lighthouse Programmatically?
Google's Lighthouse is a flexible suite of quality analysis tools for websites, covering performance, accessibility, SEO, and related areas. The simplest entry point is Chrome's DevTools panel, where a click on "Generate Report" produces a full evaluation. But for larger applications, the ability to trigger Lighthouse runs from JavaScript opens up more interesting possibilities.
Running Lighthouse programmatically matters when you need to move beyond one-off, manual checks. It becomes practical to:
- Test multiple URLs or page variants in a single pass, each with its own data and markup.
- Gather results from many runs to compile, compare, or analyze them in custom ways.
- Embed quality checks into a CI pipeline so regressions are caught before they reach production.
Programmatic control isn't the right fit for every project — Google's own documentation covers several other invocation methods worth reviewing. But when you need custom runs or custom result handling, this approach gives you the freedom to build exactly what your site requires.
The Three Inputs to a Programmatic Run
In the core Lighthouse function, you supply three parameters: the URL to test, Chrome launch options, and a Lighthouse configuration object. Each one is a plain JavaScript object you can construct with whatever logic you need.
For example, a site with multiple pages can generate URL strings in a loop and invoke Lighthouse for each. Chrome launcher options — such as an array of chromeFlags or a specific port — are passed through as an object to the chrome-launcher package. The Lighthouse configuration object is where you control the audit itself: which categories to run, which device profile to emulate, and whether extra headers need to be sent.
function launchChromeAndRunLighthouse(url, opts, config = null) {
return chromeLauncher.launch({chromeFlags: opts.chromeFlags}).then(chrome => {
opts.port = chrome.port;
return lighthouse(url, opts, config).then(results => {
return chrome.kill().then(() => results.lhr)
});
});
}
Two configuration options deserve particular attention. emulatedFormFactor accepts mobile or desktop, letting you simulate either kind of device. extraHeaders is the place for cookies or authentication tokens the browser might need, such as when analyzing a page behind a login wall.
A minimal configuration for an accessibility-only audit on a desktop emulator, returning results as HTML, could look like this:
const lighthouseOptions = {
extends: 'lighthouse:default',
settings: {
onlyCategories: ['accessibility'],
emulatedFormFactor:'desktop',
output: ['html'],
},
}
That example is pared down; the official configuration documentation lists far more possibilities, and the Lighthouse repository includes sample configs for more demanding setups.
Reading Results: Reports and the LHR Object
Once a programmatic run finishes, the returned object holds two keys: report and lhr.
The report key contains the results formatted according to the output array you passed in the config. If you asked for ['html', 'json'], then results.report[0] is the HTML report and results.report[1] is the JSON version. All of these use Lighthouse's standard reporting template — the same visual layout you see inside DevTools.
The more flexible piece is the LHR — the Lighthouse Result object. It contains raw outcomes and run metadata, documented in the project's GitHub repository. With the lhr object in hand, you can build custom reporting dashboards, store scores in a database, or run aggregate statistics across multiple sites or test runs.
Case Study: A Two-Form-Factor Accessibility Gate
Consider a site that serves different components depending on viewport size, meaning the HTML delivered to a phone differs from the HTML delivered to a desktop. Suppose you want a threshold of 95 on Lighthouse's accessibility score for both versions, enforced on every commit to the main branch.
One clean pattern is to define an array containing the two distinct configuration objects — one for desktop and one for mobile:
const lighthouseOptionsArray = [
{
extends: 'lighthouse:default',
settings: {
onlyCategories: ['accessibility'],
emulatedFormFactor:'desktop',
output: ['html', 'json'],
},
},
{
extends: 'lighthouse:default',
settings: {
onlyCategories: ['accessibility'],
emulatedFormFactor:'mobile',
output: ['html', 'json'],
},
},
]
Next, write a loop that iterates through that array, launching a Lighthouse run for each entry. A small but essential detail: Chromium can get confused if you launch runs back-to-back with no pause. Building a wait helper that wraps setTimeout in a promise gives the browser time to settle between audits.
function wait(val) {
return new Promise(resolve => setTimeout(resolve, val));
}
function launchLighthouse(optionSet, opts, results) {
return chromeLauncher
.launch({ chromeFlags: opts.chromeFlags })
.then(async chrome => {
opts.port = chrome.port;
try {
results = await lighthouse(url, opts, optionSet);
} catch (e) {
console.error("lighthouse", e);
}
if (results) reportResults(results, runEnvironment, optionSet, chrome);
await wait(500);
chrome.kill();
});
}
async function runLighthouseAnalysis() {
let results;
const opts = {
chromeFlags: ["--no-sandbox", "--headless"]
};
for (const optionSet of lighthouseOptionsArray) {
console.log("****** Starting Lighthouse analysis ******");
await launchLighthouse(optionSet, opts, results);
}
}
Each run then hands its outcomes to a reportResults function, which is responsible for three jobs: storing files locally, printing summaries to the console, and deciding whether the scores clear the threshold.
async function reportResults(results, runEnvironment, optionSet, chrome) {
if (results.lhr.runtimeError) {
return console.error(results.lhr.runtimeError.message);
}
await writeLocalFile(results, runEnvironment, optionSet);
printResultsToTerminal(results.lhr, optionSet);
return passOrFailA11y(results.lhr, optionSet, chrome);
}
File storage relies on the order of the output array. If the config requests HTML before JSON, report[0] is HTML and report[1] is JSON. A writeToLocalFile function can then route each to the correct directory — for instance, JSON artifacts for CI and HTML documents for manual local inspection — and give each file a meaningful, custom name.
function createFileName(optionSet, fileType) {
const { emulatedFormFactor } = optionSet.settings;
const currentTime = new Date().toISOString().slice(0, 16);
const fileExtension = fileType === 'json' ? 'json' : 'html';
return `${currentTime}-${emulatedFormFactor}.${fileExtension}`;
}
function writeLocalFile(results, runEnvironment, optionSet) {
if (results.report) {
const fileType = runEnvironment === 'ci' ? 'json' : 'html';
const fileName = createFileName(optionSet, fileType);
fs.mkdirSync('reports/accessibility/', { recursive: true }, error => {
if (error) console.error('error creating directory', error);
});
const printResults = fileType === 'json' ? results.report[1] : results.report[0];
return write(printResults, fileType, `reports/accessibility/${fileName}`).catch(error => console.error(error));
}
return null;
}
Console output serves as a quick pass/fail summary without requiring anyone to open a file mid-run.
function printResultsToTerminal(results, optionSet) {
const title = results.categories.accessibility.title;
const score = results.categories.accessibility.score * 100;
console.log('\n********************************\n');
console.log(`Options: ${optionSet.settings.emulatedFormFactor}\n`);
console.log(`${title}: ${score}`);
console.log('\n********************************');
}
The decisive step is the threshold check. Comparing each run's accessibility score against the 95-point gate determines whether the overall task fails — and, by extension, whether the CI build fails — when standards slip.
function passOrFailA11y(results, optionSet, chrome) {
const targetA11yScore = 95;
const { windowSize } = optionSet;
const accessibilityScore = results.categories.accessibility.score * 100;
if (accessibilityScore) {
if (windowSize === 'desktop') {
if (accessibilityScore < targetA11yScore) {
console.error(`Target accessibility score: ${targetA11yScore}, current accessibility score ${accessibilityScore}`);
chrome.kill();
process.exitCode = 1;
}
}
if (windowSize === 'mobile') {
if (accessibilityScore < targetA11yScore) {
console.error(`Target accessibility score: ${targetA11yScore}, current accessibility score ${accessibilityScore}`);
chrome.kill();
process.exitCode = 1;
}
}
}
}
That example is intentionally simple. But it demonstrates the pattern for far more elaborate setups: define configurations around your site's constraints, automate the runs in your existing build tooling, and turn Lighthouse scores into hard gates that stop lower-quality code from shipping.



