completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Scripting Lighthouse

Run Lighthouse from Node, sign in before it tests, script user flows with Puppeteer, and add your own audits. Recorded with Lighthouse 13.6.0 against a small account site and the course's store.

By the end, you can
  • 01
    Run it from codeCall Lighthouse from Node with flags, a config and the reports you want, and run it more than once.
  • 02
    Test behind a loginSign in with Puppeteer, a header or kept storage, then check that the page tested is the page you asked for.
  • 03
    Add your own checksWrite a gatherer, an audit and a config for a rule of your own, and know when a plugin can share it.

Lighthouse from your own code

Leaf and Kettle now has accounts. When Asha signs in, she sees her orders: mint tea, 120 grams, delivered. That page lives at /account, and it should be fast too.

In the last four lessons, you read one Lighthouse report at a time. You can make each one by hand: open DevTools, click a button, and wait. That works once. But the store changes every week, and nobody wants to click that button after every change. So you write a script: a small Node program that runs Lighthouse for you, the same way every time.

The course wrote that script, with Lighthouse 13.6.0, and pointed it at a small copy of the account site. It came back with a Performance score of 100. Then the course looked at which page Lighthouse had really tested:

The script's first runPop out in the code editor (opens in a new tab)JavaScript
// The script's first run on the account page, as the course recorded it.const run = { asked: "/account", tested: "/login", score: 1 }; console.log(`Asked for ${run.asked}.`);console.log(`Tested ${run.tested}.`);console.log(`Performance score: ${run.score * 100}.`);
  • Line 1 is a comment.
  • Line 2 holds three things the run recorded: the page the script asked for, the page Lighthouse really tested, and the Performance score, from 0 to 1.
  • Lines 4 to 6 print Asked for /account., Tested /login. and Performance score: 100.

The script asked for the account page. Lighthouse tested the sign-in page instead, and gave it 100. Nothing crashed. The score looked perfect. Two questions follow:

  • How did a test of the account page end up on the sign-in page?
  • How do you get Lighthouse past the sign-in, and make it check the store's own rules too?

The answers share one idea. A script can control three moments of a run: before it starts, the run itself, and after it ends. Here is the whole plan at once:

Three windows, left to right, joined by arrows. Before the run: sign in first, or send a header. The run: flags, a config, and your own audits. After the run: check the final URL, read the scores, and save the reports.beforesign in firstor send a headerthe runflagsa configyour own auditsaftercheck final URLread the scoressave the reports
A script controls three moments of a Lighthouse run. The rest of this lesson fills in each card, from left to right.

Before the run, the script can sign in. During the run, it chooses the settings and adds checks of its own. After the run, it reads the result: which page was tested, the scores, and the reports to save.

Real-life analogyBooking the inspector every week, with a note

In How Lighthouse works, Lighthouse was a home inspector who visits once. Now you book them for every Monday. You leave a note on the door: where the key is, which rooms to check, and two extra things you care about. Each week they follow the same note, so this week's report can be compared with last week's.

In real life: A visit booked for every Monday
In Lighthouse: A script that runs Lighthouse again and again
In real life: Where the key is
In Lighthouse: Signing in before the run
In real life: Which rooms, and two extra things to check
In Lighthouse: Flags, a config, and your own audits
In real life: The same note every week
In Lighthouse: The same settings every run, so results compare
In real life: The report on your doormat
In Lighthouse: The Lighthouse Result your script gets back
The main idea

A script decides what happens before, during and after a Lighthouse run, and does it the same way every time. Then it checks the final URL. A score is for the page Lighthouse ended up on, which may not be the page you asked for.

The rest of this lesson builds the script one piece at a time:

  • The Node API: call Lighthouse, get the reports back, and run it more than once.
  • Logins: get past the sign-in page, and check that you did.
  • User flows: measure the sign-in itself.
  • Custom audits: add the store's own rules.

The Node API

An API is the set of functions a library lets you call. Lighthouse's Node API is mainly one function, lighthouse(). How Lighthouse works called it with two things. It can take three:

  1. The address of the page to test.
  2. Flags: settings for this one run, written as an object. Most are the same settings you can type on the command line, such as onlyCategories.
  3. A config, if you want one. It's a longer set of instructions, such as extra checks to run. The section on custom audits writes one.

A few flags work differently from Node. Lighthouse's docs list them in a table. Two of them matter here:

  • port: "Only specifies which port to use, Chrome is not launched for you." So your script starts Chrome itself. The port is the numbered door that lets Lighthouse drive that Chrome.
  • configPath: "Ignored, pass the config in as the 3rd argument to lighthouse." On the command line you name a config file. From Node, you pass the config itself.

Here is a script that asks for two kinds of report from one run:

Two reports from one runJavaScript
import { writeFileSync } from "node:fs";import lighthouse from "lighthouse";import * as chromeLauncher from "chrome-launcher"; const chrome = await chromeLauncher.launch({ chromeFlags: ["--headless=new"] });const flags = { port: chrome.port, onlyCategories: ["performance"], output: ["json", "html"] };const { lhr, report } = await lighthouse("http://127.0.0.1:8080/login", flags); writeFileSync("report.json", report[0]);writeFileSync("report.html", report[1]);console.log(lhr.categories.performance.score);await chrome.kill();
  • Lines 1 to 3 load three things: a function that writes files, Lighthouse, and chrome-launcher, a small package that starts Chrome.
  • Line 5 starts Chrome without a window.
  • Line 6 sets the flags. port says where that Chrome is. onlyCategories asks for Performance only. output asks for two formats: JSON, for code to read, and HTML, for people.
  • Line 7 runs Lighthouse. It gives back two things. lhr is the Lighthouse Result: one big object with everything the run found. report holds the reports as text.
  • Lines 9 and 10 save the two reports to files.
  • Line 11 prints the Performance score, from 0 to 1. Line 12 closes Chrome.

With one format, report is one string. With two, the course's run gave back an array of 2 strings, in the order asked: the JSON first, then the HTML. Why it matters: lines 9 and 10 rely on that order. Swap the formats in line 6, and each file gets the other report.

Run it more than once

Remember the bathroom scale from The Lighthouse score. Step on it twice, and you can get two different readings. So you weigh yourself a few times and take the middle one. Lighthouse's scores wobble the same way, so a script should run it several times too.

Lighthouse's docs on variability put a number on it: "The median Lighthouse score of 5 runs is twice as stable as 1 run." The median is the middle value, once the values are sorted. The same page gives three tips:

  • Run them one after another. The docs say, in capitals: "DO NOT collect multiple Lighthouse reports at the same time on the same machine." Runs at the same time compete for the same processor.
  • Let Lighthouse pick the middle run. From Node, it has a helper called computeMedianRun. Give it several results. It picks the run closest to the middle on two metrics: when the first text or image appeared, and when the page became fully interactive.
  • Or use Lighthouse CI. It's a separate tool. The docs call it "the simplest way to run Lighthouse multiple times and also get a median run".

Why it matters: a script that fails when the score drops below a line can fail on a day when nothing changed, if it reads one run. The docs advise basing such lines on "aggregate values like the median", "instead of single test results."

Testing pages behind a login

Back to the first run. Why did Lighthouse test /login? Lighthouse's docs explain: "Default runs of Lighthouse load a page as a 'new user', with no previous session or storage data."

Here is what that means on the account site. When Asha signs in, the server gives her browser a session cookie. A cookie is a small note the browser keeps for a site, and sends back with every request to it. This one says, in effect, "this is Asha, and she signed in".

The account site sends /account only to a browser that has signed in: one with that cookie, or with a token in a header. Any other browser gets a redirect: an answer that says "not here, go to /login". The script's Chrome had never signed in. So it had no cookie, the server sent it to the sign-in page, and Lighthouse tested the page it ended up on.

Real-life analogyThe inspector inspected the lobby

You book the inspector for flat 4B. They arrive, but the flat is locked, and nobody gave them a key. The concierge points them to the lobby. They inspect the lobby carefully, and their report says everything is in great shape. Every word is true. It's just about the lobby.

In real life: Flat 4B, the place you booked
In Lighthouse: /account, the page you asked for: lhr.requestedUrl
In real life: No key
In Lighthouse: No session cookie
In real life: The concierge pointing to the lobby
In Lighthouse: The server's redirect to /login
In real life: The lobby, the place inspected
In Lighthouse: The sign-in page: lhr.finalDisplayedUrl
In real life: A glowing report
In Lighthouse: A score of 100, for the sign-in page

Check which page was tested

The result says which page Lighthouse tested. Two fields of lhr hold it. requestedUrl is the address you gave. finalDisplayedUrl is the address of the page Lighthouse ended up testing. A script can compare them:

Asked for one page, tested anotherPop out in the code editor (opens in a new tab)JavaScript
// Two fields of the first run's Lighthouse Result, shortened to their paths.const lhr = { requestedUrl: "/account", finalDisplayedUrl: "/login" }; if (lhr.finalDisplayedUrl !== lhr.requestedUrl) {  console.log(`Careful: asked for ${lhr.requestedUrl}, but tested ${lhr.finalDisplayedUrl}.`);}
  • Line 2 holds the two fields from the first run, shortened to their paths.
  • Line 4 checks whether they differ.
  • They do, so line 5 prints Careful: asked for /account, but tested /login.

Why it matters: put this check in every script. Without it, the sign-in page's score can stand in for your account page's, and nothing in the score tells you.

Ways past the sign-in

So the script has to sign in, or prove who it is some other way. Lighthouse's docs on authenticated pages list five ways:

  1. Script the sign-in with Puppeteer. The docs call this "the most flexible approach".
  2. Use the Lighthouse panel in DevTools. It "will never clear your cookies", so you can sign in first, then run it.
  3. Send a header with every request. A header is a line of extra information that travels with a request. In Node, the flag is extraHeaders.
  4. Open a Chrome with a debugging port, sign in by hand, and run Lighthouse on it with --disable-storage-reset.
  5. Reuse a Chrome user profile you prepared beforehand. A profile is the folder where Chrome keeps one person's cookies, history and bookmarks.

Start with the first. Puppeteer is a library that drives Chrome from code: it opens pages, types, and clicks. Here it signs in, in the same Chrome that Lighthouse will use:

Sign in, then testJavaScript
const page = await browser.newPage();await page.goto("http://127.0.0.1:8080/login");await page.type("#user", "tea-lover");await Promise.all([page.waitForNavigation(), page.click("#sign-in")]); const { lhr } = await lighthouse("http://127.0.0.1:8080/account", { port: chrome.port });console.log(lhr.finalDisplayedUrl);
  • Line 1 opens a new tab. browser is Puppeteer's connection to the Chrome the script started.
  • Line 2 goes to the sign-in page. Line 3 types a name into the form.
  • Line 4 clicks Sign in, and waits for the page the click leads to. On the way, the server hands Chrome the session cookie.
  • Line 6 runs Lighthouse on the account page, in the same Chrome. Line 7 prints the page it tested.

The course's run ended on /account: the page it asked for. The cookie was already in that Chrome, so it went with the request.

The course tried other ways too, and logged every request the account site received. Pick a way to see what the site saw, and which page Lighthouse tested:

Playground: testing behind a login
What the server receivedrecorded
Try it yourself
Asked for /account, tested /login

No session cookie, so the server sent /account on to /login. Lighthouse tested the sign-in page instead, and scored it 100.

Recorded with Lighthouse 13.6.0 from Node, against a small copy of the account site. The list shows every request the site received during the run, and whether the session cookie or the token came with it. The cookie and the token are made up.

Here are five of those runs side by side:

Asked for, and testedPop out in the code editor (opens in a new tab)JavaScript
// Each way of testing a page behind the login: the page asked for, the page Lighthouse tested, and the score.const runs = {  "fresh browser": { asked: "/account", tested: "/login", score: 1 },  "signed in with Puppeteer": { asked: "/account", tested: "/account", score: 1 },  "Authorization header": { asked: "/account", tested: "/account", score: 1 },  "token in localStorage": { asked: "/orders", tested: "/orders", score: 1 },  "token page, fresh browser": { asked: "/orders", tested: "/login-token", score: 1 },}; for (const [way, { asked, tested, score }] of Object.entries(runs)) {  console.log(`${way}: asked for ${asked}, tested ${tested}, score ${Math.round(score * 100)}`);}const matched = Object.values(runs).filter(({ asked, tested }) => asked === tested).length;console.log(`tested the page asked for: ${matched} of ${Object.keys(runs).length}`);
  • Lines 3 to 7 hold five recorded runs: the page each asked for, the page it tested, and its score.
  • Lines 10 to 12 print one line per run. The first is fresh browser: asked for /account, tested /login, score 100.
  • Line 13 counts the runs whose two pages match. Line 14 prints tested the page asked for: 3 of 5.

Every run scored 100, including the two that tested a sign-in page. So the score can't tell you which page it's for. Only the final URL can.

The last run shows one more thing. The token page has a script that checks for the token, and sends the browser to /login-token if there's none. So a redirect doesn't have to come from the server. The page's own JavaScript can send Lighthouse somewhere else too.

Why the sign-in lasted

How Lighthouse works showed that Lighthouse resets the browser before each run. So why didn't the reset sign the script out? Because it clears only some kinds of storage:

Before each run, Lighthouse clears service workers, Cache Storage, file systems, shader cache, browser cache. The signed-in session cookie survived two runs, and a token in localStorage survived too.cleared before each runservice workersCache Storagefile systemsshader cachebrowser cachekept, and still signed insession cookielocalStorage token
Before each run, Lighthouse clears the five stores on the left. It leaves cookies and localStorage alone, so a sign-in made in the same browser lasts.

The reset clears caches and service workers. It doesn't touch cookies, or localStorage: a store where a page's JavaScript can keep small pieces of text, like a token. It's like a cleaner who empties the bins before each visit, but leaves the keys on their hook.

The recording shows it. The session cookie went with the request in two runs, one straight after the other. The token in localStorage was found too.

One warning, about headers. You could send a Cookie header with extraHeaders, "but beware: it will override any other Cookies you expect to be there." For sign-ins that use cookies, the docs point to Puppeteer instead.

User flows with Puppeteer

Once the script was signed in, the account page scored 100. But what about signing in itself? Asha types her name, taps Sign in, and waits. That wait is a page load too, started by a click.

How Lighthouse works compared Lighthouse's modes to a shop's security camera: film the opening, film one customer, or take a photo. A user flow keeps several of those clips in one report, in order. Each clip is a step.

A step that loads a page is a navigation. Usually you give it an address. You can also give it a function that does something on the page, such as a click. The docs say this function "must perform an action that will trigger a navigation." They add: "Any interactions completed before the callback promise resolves will be captured by the navigation." In plain words: everything the function does, until it finishes, belongs to the step.

A flow that signs inJavaScript
const page = await browser.newPage();const flow = await startFlow(page); await flow.navigate("http://127.0.0.1:8080/login", { name: "Sign-in page" });await flow.navigate(async () => {  await page.type("#user", "tea-lover");  await page.click("#sign-in");}, { name: "Sign in" });await flow.snapshot({ name: "Signed in" }); writeFileSync("flow.html", await flow.generateReport());
  • Line 1 opens a tab. Line 2 starts a flow in it.
  • Line 4 is the first step: a plain navigation to the sign-in page, named Sign-in page.
  • Lines 5 to 8 are the second step, named Sign in. Instead of an address, it gets a function. Line 6 types the name, and line 7 clicks Sign in. Lighthouse measures the page load that the click starts.
  • Line 9 is a snapshot: a look at the page as the sign-in left it, with no timing. It's named Signed in.
  • Line 11 writes one report with all three steps.

The course's flow recorded 3 steps: Sign-in page, a navigation; Sign in, a navigation; Signed in, a snapshot.

The Sign in step started at /session and ended at /account. That's how the form works. It sends the name to /session. The server sets the session cookie there, and redirects to /account.

Lighthouse noticed the redirect, and added a warning to the step:

The page may not be loading as expected because your test URL (http://127.0.0.1:8080/session?user=tea-lover) was redirected to http://127.0.0.1:8080/account. Try testing the second URL directly.

That warning can mean a test missed its page, like the inspector in the lobby. Here, the redirect is the point: signing in sends you on to your account. Why it matters: read every warning, then decide whether it describes a mistake or the very thing you meant to measure.

Custom audits and plugins

Leaf and Kettle has two rules of its own. No built-in check knows these exact rules:

  • The hero image has a size. The hero is the big photo at the top of the page. Its HTML must give it a width and a height. Without them, the page can jump when the photo arrives.
  • JavaScript stays under 100 KiB. A KiB is 1,024 bytes. The limit counts the bytes the scripts take on the network.

To teach Lighthouse a rule, you add a check. Remember the inspector's clipboard from How Lighthouse works. During the visit, gatherers take readings and write them on the clipboard. Each reading is an artifact. Back at the office, audits check those readings, one check each. So a new rule needs up to three files:

  • an audit: the new check;
  • a gatherer, if the check needs a reading Lighthouse doesn't take already;
  • a config: the instructions that add both to a run.

Lighthouse's recipe says a config "tells Lighthouse where to find the gatherer and audit files, when to run them, and how to incorporate their output into the Lighthouse report."

Real-life analogyA config is the inspector's instruction card

Before the visit, you hand the inspector a card. It says: "Do your usual checklist. Also measure the frame of the big photo in the hall. Add two checks of ours, and put them on a new page of the report, called House rules." The inspector still does everything they always do. The card only adds to it.

In real life: "Do your usual checklist"
In Lighthouse: extends: "lighthouse:default"
In real life: "Also measure the photo frame in the hall"
In Lighthouse: artifacts: a new gatherer, HeroImage
In real life: "Two checks of ours"
In Lighthouse: audits: hero-has-size and script-budget
In real life: "Put them on a new page, House rules"
In Lighthouse: categories: a new category, Store checks
In real life: "Only bring me that page this time"
In Lighthouse: --only-categories=store-checks on the command line

A new reading: the hero's size

The course's hero check takes its own reading, with a gatherer it wrote. The reading is the photo's width and height attributes:

hero-gatherer.jsJavaScript
import { Gatherer } from "lighthouse"; class HeroImage extends Gatherer {  meta = { supportedModes: ["navigation", "snapshot"] };   async getArtifact(context) {    return context.driver.executionContext.evaluate(() => {      const hero = document.querySelector("#hero");      return hero && { width: hero.getAttribute("width"), height: hero.getAttribute("height") };    }, { args: [] });  }} export default HeroImage;
  • Line 1 loads Lighthouse's Gatherer class. Line 3 makes a gatherer from it, called HeroImage.
  • Line 4 says which modes it works in: navigation and snapshot.
  • Line 6 is the method Lighthouse calls to take the reading.
  • Line 7 runs a function inside the page. Line 8 finds the element with the id hero. Line 9 returns its width and height attributes, or null if there's no hero.
  • Line 10 closes that function. It passes no extra arguments into the page.

The audit reads that artifact and decides:

hero-size-audit.jsJavaScript
import { Audit } from "lighthouse"; class HeroSize extends Audit {  static get meta() {    return {      id: "hero-has-size",      title: "The hero image has a width and height",      failureTitle: "The hero image has no width and height",      description: "Without them, the page shifts when the photo arrives.",      requiredArtifacts: ["HeroImage"],    };  }   static audit(artifacts) {    const hero = artifacts.HeroImage;    const sized = Boolean(hero && hero.width && hero.height);    return { score: sized ? 1 : 0, displayValue: sized ? `${hero.width} by ${hero.height}` : "no size" };  }} export default HeroSize;
  • Lines 6 to 9 describe the audit: its id, a title for when it passes, a title for when it fails, and a description. The report shows them.
  • Line 10 lists the artifacts it reads: just HeroImage.
  • Line 15 takes the artifact. Line 16 asks: is there a hero, with both a width and a height?
  • Line 17 returns the result. The score is 1 for a pass, or 0 for a fail. displayValue is the short text the report shows next to it: the size, or no size.

A budget is a smoke alarm

The second rule is a budget: a limit you set on something you can measure, such as bytes or milliseconds. Lighthouse used to have budgets built in. Its changelog for version 12.0.0 lists "remove budgets" under breaking changes. An audit can do the same job.

Real-life analogyA budget is a smoke alarm

Picture a smoke alarm with a dial for how much smoke sets it off. You set the dial once. Below that line, it stays quiet. Above it, it goes off, every time, however tidy the kitchen looks.

In real life: The dial you set once
In Lighthouse: BUDGET_BYTES: 100 KiB of JavaScript
In real life: The smoke in the air
In Lighthouse: The page's scripts, in bytes sent over the network
In real life: The alarm going off
In Lighthouse: A score of 0: "JavaScript is over budget"
In real life: However tidy the kitchen looks
In Lighthouse: Whatever the Performance score says
script-budget-audit.jsJavaScript
import { Audit, NetworkRecords } from "lighthouse"; const BUDGET_BYTES = 100 * 1024; class ScriptBudget extends Audit {  static get meta() {    return {      id: "script-budget",      title: "JavaScript fits the budget",      failureTitle: "JavaScript is over budget",      description: "The store's budget is 100 KiB of JavaScript, as transferred.",      requiredArtifacts: ["DevtoolsLog"],    };  }   static async audit(artifacts, context) {    const records = await NetworkRecords.request(artifacts.DevtoolsLog, context);    const bytes = records.filter((record) => record.resourceType === "Script").reduce((sum, record) => sum + record.transferSize, 0);    return {      score: bytes <= BUDGET_BYTES ? 1 : 0,      numericValue: bytes,      numericUnit: "byte",      displayValue: `${Math.round(bytes / 1024)} KiB of ${BUDGET_BYTES / 1024} KiB`,    };  }} export default ScriptBudget;
  • Line 3 sets the line: 100 times 1,024 bytes, which is 100 KiB.
  • Line 12 asks for the DevtoolsLog artifact: the log of messages Chrome sent Lighthouse during the run. It holds every network request. Lighthouse already collects it, so this audit needs no gatherer of its own.
  • Line 17 turns that log into a list of network requests.
  • Line 18 does two things. It keeps only the scripts. Then it adds up each one's transferSize: the bytes it took on the network.
  • Line 20 passes at the budget or under it, and fails above it.
  • Line 23 shows the total next to the budget, in KiB.

Put both on the card: the config

custom-config.jsJavaScript
export default {  extends: "lighthouse:default",  artifacts: [{ id: "HeroImage", gatherer: "hero-gatherer" }],  audits: ["hero-size-audit", "script-budget-audit"],  categories: {    "store-checks": {      title: "Store checks",      description: "The course's own checks for the store.",      auditRefs: [        { id: "hero-has-size", weight: 1 },        { id: "script-budget", weight: 1 },      ],    },  },};
  • Line 2 starts from Lighthouse's usual config: the usual checklist.
  • Line 3 adds one artifact, HeroImage, made by the file hero-gatherer.
  • Line 4 adds the two audit files.
  • Lines 5 to 14 add a category, Store checks, with both audits in it. Each has a weight of 1, so they count the same.

A config can also hold settings. Lighthouse's configuration docs say they control things "such as CPU/network throttling and which audits should run." That's the throttling from Throttling and simulation.

The recipe adds one rule about extending: "all arrays will be concatenated and primitive values will override the defaults." In plain words: lists, like the audits, are added to Lighthouse's own lists. A single value, like one setting, replaces Lighthouse's value.

The course ran the config from the command line, in the folder with the four files:

Run the configText
npx lighthouse http://127.0.0.1:8080/ --config-path=custom-config.js --only-categories=store-checks

--config-path names the config file. --only-categories=store-checks runs only the new category. The course ran it on two stores: the store from Performance insights, and the fast store from The Lighthouse score.

What the two checks foundPop out in the code editor (opens in a new tab)JavaScript
// Each store's two custom audits, from the command line's JSON: the score, and the text the report showed.const stores = {  store: { "hero-has-size": [0, "no size"], "script-budget": [0, "152 KiB of 100 KiB"] },  "fast store": { "hero-has-size": [1, "360 by 270"], "script-budget": [0, "150 KiB of 100 KiB"] },}; for (const [name, audits] of Object.entries(stores)) {  const [hero, budget] = Object.values(audits);  const category = (hero[0] + budget[0]) / 2;  console.log(`${name}: hero image ${hero[1]}, JavaScript ${budget[1]}, Store checks ${category * 100}`);}
  • Lines 3 and 4 are recorded from each run's JSON: each audit's score, and the text the report showed.
  • Line 8 takes out the two audits. Line 9 averages their two scores.
  • Line 10 prints one line per store. The first is store: hero image no size, JavaScript 152 KiB of 100 KiB, Store checks 0.
  • The second is fast store: hero image 360 by 270, JavaScript 150 KiB of 100 KiB, Store checks 50.
The custom config adds a gatherer, hero-gatherer.js, whose HeroImage artifact hero-has-size scores, and an audit, script-budget, that reads the DevtoolsLog artifact. Both go in a Store checks category: 0 for the store, 50 for the fast store.hero-gathererHeroImagehero-has-sizeDevtoolsLogscript-budgetStore checksbuilt in0 and 50store, fast store
A gatherer takes a reading. An audit checks a reading. The category averages its two audits: 0 for the store, 50 for the fast store.

Look at the fast store. In The Lighthouse score, it scored 100 for Performance in all five runs. Its hero has a size, so the first check passes. But it still sends 150 KiB of JavaScript, because its app.js carries the whole catalog of 1,800 teas. So the alarm goes off.

Why it matters: a budget catches what a good score can hide. The score sums up five metrics. The budget watches the one thing you chose. A script can read this audit's score from lhr.audits, and stop with an error when it's 0. Then a change that adds too much JavaScript can't slip through.

Try the two rules yourself. Type a width and a height, and slide the JavaScript size. The playground uses the same rules as the audits:

Playground: the Store checks category
The artifactsyou set them
Store checkslive
hero-has-sizeno size
script-budget152 KiB of 100 KiB
0
Try it yourself
Store checks: 0

hero-has-size: no size, so it failed. script-budget: 152 KiB of 100 KiB, so it failed. Both audits weigh 1, so Store checks is their average.

Live: the two audits' own rules, run on the values you set. The two buttons load what the command line recorded for each store.

Real visits report the same kind of number through Resource Timing, so you can watch the budget for real users too:

Plugins: checks you can share

A plugin packages audits as an npm package, so other people can install them. The plugin handbook says plugins "are easily shared and have a stable API that won't change between minor version bumps". But they "are also more limited in scope than a custom Lighthouse configuration." A stable API means the parts of Lighthouse a plugin uses don't change in minor updates: the ones where only the middle number of the version goes up.

The handbook's table says what each can do. Read each row across: one ability, then whether a plugin and a custom config have it.

Plugin or custom config?
What can it do?A pluginA custom config
Add your own auditsYes, it can.Yes, it can.
Add a category of its ownYes, it can.Yes, it can.
Be shared on npm for others to installYes, it can.No, it can't.
Keep the same API through minor versionsYes, it does.No, it doesn't.
Gather new data from the page (artifacts)No, it can't.Yes, it can.
Change Lighthouse's own categoriesNo, it can't.Yes, it can.
Change settings, such as throttlingNo, it can't.Yes, it can.

So the course's hero check needs a config, because it uses a gatherer of its own. The budget reads only DevtoolsLog, which the handbook lists as available to plugins. So it could be shared as a plugin. A plugin is like a printed checklist any inspector can follow. Your own card can also ask for new readings.

Which approach?

Each job below needs one of three tools. Does it need steps in the browser, like typing and clicking? Then it's Puppeteer. Does one setting do it? Then it's a flag or a header. Does it need a check Lighthouse doesn't have? Then it's a custom audit.

Which approach?
  • Sign in through a form before testing the account page
  • Measure the page load that a click on Sign in starts
  • Test a page that accepts a token in an Authorization header
  • Keep a debug Chrome's storage between runs
  • Fail when the page's JavaScript passes 100 KiB
  • Check that the hero image has a width and height
Try it yourself
0 of 6 correct

Sort each job by the approach that fits it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A 100 means Lighthouse tested my page." No. The fresh browser's 100 was for the sign-in page. Compare finalDisplayedUrl with requestedUrl.
  • "Lighthouse signs you out before each run." No. Its reset clears caches and service workers. The session cookie and the localStorage token both survived it.
  • "A Cookie header is the easy way in." No. It replaces the other cookies the page expects. The docs point to Puppeteer for sign-ins that use cookies.
  • "One run is enough for a script." No. Scores wobble from run to run. The docs say the median of five runs is twice as stable as one.
  • "A redirect warning always means a mistake." No. In the sign-in flow, the redirect to the account page was the point.
  • "Plugins can do anything a config can." No. A plugin can't gather new data from the page or change settings.
  • "Lighthouse still has budgets." No. Version 12.0.0 removed them. A custom audit can do the job.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upA wrong token

Suppose the Authorization header had carried a wrong token. The server would send /account on to /login, as it did for the fresh browser. What would the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Each way of testing a page behind the login: the page asked for, the page Lighthouse tested, and the score.
const runs = {
  "fresh browser": { asked: "/account", tested: "/login", score: 1 },
  "signed in with Puppeteer": { asked: "/account", tested: "/account", score: 1 },
  "Authorization header": { asked: "/account", tested: "/login", score: 1 },
  "token in localStorage": { asked: "/orders", tested: "/orders", score: 1 },
  "token page, fresh browser": { asked: "/orders", tested: "/login-token", score: 1 },
};

for (const [way, { asked, tested, score }] of Object.entries(runs)) {
  console.log(`${way}: asked for ${asked}, tested ${tested}, score ${Math.round(score * 100)}`);
}
const matched = Object.values(runs).filter(({ asked, tested }) => asked === tested).length;
console.log(`tested the page asked for: ${matched} of ${Object.keys(runs).length}`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upA bigger budget

    The store raises its budget to 160 KiB. Both stores now fit it. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Each store's two custom audits, from the command line's JSON: the score, and the text the report showed.
    const stores = {
      store: { "hero-has-size": [0, "no size"], "script-budget": [1, "152 KiB of 160 KiB"] },
      "fast store": { "hero-has-size": [1, "360 by 270"], "script-budget": [1, "150 KiB of 160 KiB"] },
    };
    
    for (const [name, audits] of Object.entries(stores)) {
      const [hero, budget] = Object.values(audits);
      const category = (hero[0] + budget[0]) / 2;
      console.log(`${name}: hero image ${hero[1]}, JavaScript ${budget[1]}, Store checks ${category * 100}`);
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe fresh browser's requests

      In the logins playground, how many requests did the account site receive during the fresh browser's run?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePass both checks

        In the Store checks playground, type 360 for the width and 270 for the height, and slide JavaScript to 90 KiB. What does Store checks show?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeQuieter logs from Node

          On the command line, --quiet makes Lighthouse print less. From Node, the docs say the quiet flag is ignored. Which flag do they say to use instead?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA plugin's name

            You want to share the budget audit as a plugin on npm. The plugin handbook says the package's name must start with something. What?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask three things. Which page was tested? What kept the sign-in? What can each kind of extension do?

              Scripting Lighthouse quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the last line of the logins example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Each way of testing a page behind the login: the page asked for, the page Lighthouse tested, and the score.
                const runs = {
                  "fresh browser": { asked: "/account", tested: "/login", score: 1 },
                  "signed in with Puppeteer": { asked: "/account", tested: "/account", score: 1 },
                  "Authorization header": { asked: "/account", tested: "/account", score: 1 },
                  "token in localStorage": { asked: "/orders", tested: "/orders", score: 1 },
                  "token page, fresh browser": { asked: "/orders", tested: "/login-token", score: 1 },
                };
                
                for (const [way, { asked, tested, score }] of Object.entries(runs)) {
                  console.log(`${way}: asked for ${asked}, tested ${tested}, score ${Math.round(score * 100)}`);
                }
                const matched = Object.values(runs).filter(({ asked, tested }) => asked === tested).length;
                console.log(`tested the page asked for: ${matched} of ${Object.keys(runs).length}`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the first line of the custom category example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Each store's two custom audits, from the command line's JSON: the score, and the text the report showed.
                const stores = {
                  store: { "hero-has-size": [0, "no size"], "script-budget": [0, "152 KiB of 100 KiB"] },
                  "fast store": { "hero-has-size": [1, "360 by 270"], "script-budget": [0, "150 KiB of 100 KiB"] },
                };
                
                for (const [name, audits] of Object.entries(stores)) {
                  const [hero, budget] = Object.values(audits);
                  const category = (hero[0] + budget[0]) / 2;
                  console.log(`${name}: hero image ${hero[1]}, JavaScript ${budget[1]}, Store checks ${category * 100}`);
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7Lighthouse gives your account page a 100, and the script never signed in. What should you check first?

                Choose an answer to see the explanation.

              4. Question 4 of 7Why might sending a Cookie header with extraHeaders cause trouble?

                Choose an answer to see the explanation.

              5. Question 5 of 7Your script will fail a check when the Performance score drops below 90. What should it compare with 90?

                Choose an answer to see the explanation.

              6. Question 6 of 7Your audit needs a gatherer of its own, to collect something from the page that Lighthouse doesn't. Plugin or custom config?

                Choose an answer to see the explanation.

              7. Question 7 of 7How do you measure the page load that a click on a Sign in button starts, in a user flow?

                Choose an answer to see the explanation.

              Key takeaways

              • A script runs Lighthouse the same way every time. It controls what happens before, during and after the run.
              • lighthouse() takes an address, flags for this run, and an optional config. Ask for two outputs, and the reports come back in the order you asked.
              • Run it several times, one after another, and use the median, not one run.
              • A fresh browser has no session. Sign in with Puppeteer in the same browser, or send a header. Lighthouse's reset keeps cookies and localStorage.
              • Always compare the final URL with the one you asked for. A run sent to a sign-in page still scores.
              • A user flow can measure the page load that a click starts, sign-ins included.
              • A custom audit needs an audit and a config, plus a gatherer when it needs new data. Plugins share audits, but can't gather new data.

              Remember the one-liner.
              Script the visit, add your own checks, and confirm which page was tested.

              Sources

              The facts on this page come from these docs and source files:

              Coming next: WebPageTest basics, also in Measuring in the lab. Test from real locations and devices, then read the filmstrip, the video, and the waterfall.

              CompleteFrontend Clear concepts. Working examples.