Lighthouse as the pre-launch gate
Run Lighthouse as a one-off pre-launch audit and a recurring CI gate that fails a pull request when a performance budget busts.
The product ships in two weeks, and you need two things before then. A structural gate that fails a pull request before slow code merges, not a dashboard someone checks three weeks later. And a one-off deep pass over the pages that matter, the landing page and the dashboard, so launch day doesn’t ship a four-second hero image nobody profiled.
Both jobs are Lighthouse, which seems to contradict where this chapter started. Core Web Vitals drew a hard line: field data is the verdict and Lighthouse is the regression catcher, so chase your real-user numbers, not a synthetic score. That holds after launch, not before.
Before launch there is no field data. The Core Web Vitals in Speed Insights come from CrUX , which needs real traffic to exist, and an unlaunched product has none. So in these two weeks the synthetic lab run isn’t the inferior signal you’d set aside for field data, it’s the only signal you have. Lighthouse becomes the regression catcher once real users arrive; until then it’s the whole picture.
This lesson sets up Lighthouse in both postures: the pre-launch deep pass you run once, and the CI regression gate you run forever.
By the end you’ll have written a lighthouserc.json, the single config file that encodes the gate, with thresholds calibrated for a 2026 SaaS.
What Lighthouse measures, and the Vital it can’t
Section titled “What Lighthouse measures, and the Vital it can’t”A Lighthouse run is one synthetic page load on a fixed profile: a simulated Slow 4G connection, a throttled CPU that mimics a mid-tier mobile phone, no extensions, and a cold cache. Because the profile never varies, Lighthouse is your regression catcher. When a number moves between two runs, the network and the test phone were identical, so the only thing that changed is your code. Field data can’t isolate that: a CrUX regression might be your change or just a bad week of mobile traffic.
A run scores four categories: Performance, Accessibility, Best Practices, and SEO. A fifth, PWA, was removed in Lighthouse 12, so a tutorial showing a Progressive Web App score is out of date.
The category you came for is Performance, a weighted blend of five lab metrics:
Two are minor at 10% each: FCP (when the first content paints) and Speed Index .
The other three carry the lesson.
LCP and CLS are the same two Vitals from Core Web Vitals, and together they are half the Performance score, so work you already know moves the number directly: the next/image preload from Preloading the LCP element drives LCP, and reserving space for elements keeps CLS down.
The third Vital, INP, is missing, and this is the correction. Lighthouse cannot measure INP , because a lab run has no user and so no interaction to time. In its place it measures TBT , a partial proxy: a page whose main thread is jammed during load will likely be slow to respond to clicks too, so high TBT is your pre-launch warning that field INP will be poor. It catches the input-delay part of INP and nothing else. So never quote an INP number from a Lighthouse report, because there isn’t one. Read TBT as the early-warning signal, recall that the real INP fix is to ship less client JavaScript, and get a true INP number from Speed Insights or the DevTools Performance panel.
Three ways to run Lighthouse, and when to use each
Section titled “Three ways to run Lighthouse, and when to use each”Lighthouse runs from three surfaces, and they aren’t interchangeable. Pick by what you’re trying to do right now, then let the walker route you.
One click, local, instant. Run it against a production build (pnpm build then pnpm start), never pnpm dev. Localhost reads artificially fast, so trust relative movement between runs rather than the absolute numbers.
Hosted Lighthouse on Google’s infrastructure plus a CrUX field-data overlay, the pre-launch choice for the marketing page. Before launch the field section is empty because there’s no traffic yet; that’s expected, and you’re reading lab-only.
Runs against a built app, asserts your thresholds, and fails the build when a budget busts. This is the recurring gate, and the rest of this lesson builds it.
The two pages you audit and their budgets
Section titled “The two pages you audit and their budgets”A gate is only as good as the numbers it checks, so before wiring it up you need targets and the pages to apply them to.
You don’t audit every route. A SaaS has two performance regimes, and two pages cover both. The first is the marketing or landing page: your highest-traffic surface, the SEO-sensitive first impression, mostly static. The second is one critical authenticated screen, the dashboard home or primary task screen, whichever ships the most JavaScript. That second page is the realistic interactive worst case: behind login, heavier, and where bundle bloat shows up. Every other page is a variation on one of these two, so auditing both covers the static-marketing regime and the JS-heavy-app regime.
Treat these as 2026 SaaS defaults you tighten quarterly, not laws.
Every number here ties back to something you already know.
CLS and the marketing LCP match the good Vital bands from the first lesson: CLS ≤ 0.1 on both rows, and LCP ≤ 2.5 s on the marketing page. You want the synthetic run to clear the same bar real users are scored against. The dashboard gets a looser 3.0 s for the same reason its other budgets loosen: an interactive screen legitimately runs more JavaScript on load.
TBT stands in for an INP budget. There is no lab INP, so there’s no INP number to assert; you budget TBT instead as the proxy. The dashboard’s looser 300 ms follows from that same extra JavaScript on load.
The JS budgets lead straight back to the treemap. Reading the bundle treemap taught you to find where the bytes went; this is where you cap them. The dashboard’s larger JS budget is also why it scores lower: more JavaScript means more main-thread work, which means higher TBT, which means a lower Performance score.
The cheat sheet deliberately does not say “100 everywhere.” The jump from 90 to 100 often takes micro-optimizations that change nothing for a user on a flaky mobile connection, and that user, not the engineer on fiber, is who the score protects.
The budget that applies depends on what a page is, not its name. Sort each page below into its audit profile.
Each page below gets audited against one of the two profiles in the cheat sheet. Sort by which budget set applies — judge by what the page *is*, not by its name. Drag each item into the bucket it belongs to, then press Check.
Wiring the CI gate with @lhci/cli
Section titled “Wiring the CI gate with @lhci/cli”This is the recurring posture, the floor that holds as the app grows. The whole gate lives in one config file.
Install it as a dev dependency:
pnpm add -D @lhci/cliThe current 0.15.x line bundles Lighthouse 12, the version whose Performance score you just learned, and runs on Node 24, the course default. Don’t memorize the version literal; LHCI moves fast and you’ll get whatever’s current.
Everything else is one file, lighthouserc.json, with three parts: what to audit (collect), what to assert (assert), and where the report goes (upload).
{ "ci": { "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/dashboard"], "startServerCommand": "pnpm start", "numberOfRuns": 3 }, "assert": { "preset": "lighthouse:recommended", "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], "total-blocking-time": ["error", { "maxNumericValue": 300 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }], "resource-summary:script:size": ["error", { "maxNumericValue": 358400 }] } }, "upload": { "target": "temporary-public-storage" } }}collect lists the two surfaces from the cheat sheet, the marketing root and the dashboard, and startServerCommand boots your production app with pnpm start. Build it first.
{ "ci": { "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/dashboard"], "startServerCommand": "pnpm start", "numberOfRuns": 3 }, "assert": { "preset": "lighthouse:recommended", "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], "total-blocking-time": ["error", { "maxNumericValue": 300 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }], "resource-summary:script:size": ["error", { "maxNumericValue": 358400 }] } }, "upload": { "target": "temporary-public-storage" } }}A single run is noisy: one slow garbage-collection pause can swing a metric. numberOfRuns: 3 runs three times per URL and takes the median, a number stable enough to assert against.
{ "ci": { "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/dashboard"], "startServerCommand": "pnpm start", "numberOfRuns": 3 }, "assert": { "preset": "lighthouse:recommended", "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], "total-blocking-time": ["error", { "maxNumericValue": 300 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }], "resource-summary:script:size": ["error", { "maxNumericValue": 358400 }] } }, "upload": { "target": "temporary-public-storage" } }}assert is the heart of the gate. The lighthouse:recommended preset pulls in Lighthouse’s full recommended assertion set, so you check dozens of audits for free.
{ "ci": { "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/dashboard"], "startServerCommand": "pnpm start", "numberOfRuns": 3 }, "assert": { "preset": "lighthouse:recommended", "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], "total-blocking-time": ["error", { "maxNumericValue": 300 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }], "resource-summary:script:size": ["error", { "maxNumericValue": 358400 }] } }, "upload": { "target": "temporary-public-storage" } }}These overrides tie the gate to your cheat sheet. minScore: 0.9 is the ≥ 90 score (scores run 0–1, so 0.9 = 90). The per-metric budgets follow: LCP and TBT in milliseconds, CLS unitless. These lines fail the build when a budget busts.
{ "ci": { "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/dashboard"], "startServerCommand": "pnpm start", "numberOfRuns": 3 }, "assert": { "preset": "lighthouse:recommended", "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], "total-blocking-time": ["error", { "maxNumericValue": 300 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }], "resource-summary:script:size": ["error", { "maxNumericValue": 358400 }] } }, "upload": { "target": "temporary-public-storage" } }}resource-summary:script:size caps shipped JavaScript in bytes, so the cheat sheet’s 350 KB becomes 358400. Bust it and the pull request fails mechanically, no human judgment required.
{ "ci": { "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/dashboard"], "startServerCommand": "pnpm start", "numberOfRuns": 3 }, "assert": { "preset": "lighthouse:recommended", "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], "total-blocking-time": ["error", { "maxNumericValue": 300 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }], "resource-summary:script:size": ["error", { "maxNumericValue": 358400 }] } }, "upload": { "target": "temporary-public-storage" } }}temporary-public-storage takes zero setup and gives a shareable report URL per run. The heavier alternative, a self-hosted LHCI server for historical trends, this lesson names but doesn’t build.
Two ideas in that config carry the lesson’s weight.
The first: assert the metric budget, not the aggregate score.
The gate should fail when LCP busts 2.5 seconds or the JS budget blows its cap, not when the score drifts from 95 to 93.
The score is diagnostic, telling a human where to look; the assertions are structural protection, failing the build mechanically.
This is the chapter’s through-line, structural protection over vanity metric, the same shape as the ESLint ban on raw <img> in Preloading the LCP element and sideEffects: false in Barrel files and tree-shaking.
The second: the resource-summary line is a performance budget.
Beyond JS size, LHCI can cap image bytes, font bytes, total page weight, and timing budgets like LCP and FCP.
Set one budget per resource class; JS is the central one for a web app, since JavaScript drives TBT, which drives the score.
One honest simplification: this config applies one assertion set to both URLs, so the marketing page is held to the dashboard’s looser 350 KB budget rather than its own 200 KB.
That’s fine as a starting gate, since it still catches a runaway dependency.
For a tighter per-surface budget, assertMatrix keys different assertions to different URL patterns once the basic gate is in place.
To run it, add one script to package.json:
"scripts": { "lhci": "lhci autorun"}autorun chains a healthcheck, then collect, assert, and upload, exiting non-zero if any assertion fails. A CI system reads that non-zero exit as a failed build.
The conceptual flow on a pull request that touches UI or dependencies is to build the app, start it, run lhci autorun, and let CI fail if any assertion does.
Reading a Lighthouse report
Section titled “Reading a Lighthouse report”A report’s job is to become an action list, not a number. Read it top to bottom in four passes, but act on the third pass first.
The score comes first only so you can set it aside: it’s an aggregate, and everything useful is in the breakdown beneath it. Don’t chase the number, find the red metric.
The metrics strip shows which metric is red, and the metric tells you the fix. Red LCP sends you to the image work in Preloading the LCP element; red TBT sends you to the client-JavaScript work in the first lesson and the bundle triage in Reading the bundle treemap. Same color, opposite fixes.
Act on Opportunities first, because each one is ranked by the seconds it would save and almost all of them map to a fix this chapter already taught.
Oversized images point to next/image with preload and correct sizing in Preloading the LCP element.
Unused JavaScript and bundle weight point to optimizePackageImports in Barrel files and tree-shaking and the treemap triage in Reading the bundle treemap.
Render-blocking resources point to font and CSS handling.
Diagnostics carry no time estimate, so read them last. Long main-thread tasks are the TBT story again, pointing back at client JavaScript.
This is the chapter’s routing rule. A red metric is the symptom; the tool that gives you the diagnosis depends on which metric is red. Bundle weight goes to the treemap in Reading the bundle treemap; a slow render goes to the RSC waterfall in a Sentry trace (the next lesson); a database-bound slow TTFB goes to the query work in the lesson after. Lighthouse tells you that the page is slow and roughly where; the other tools tell you why.
Match each Lighthouse signal to the tool or fix you’d reach for.
A red Lighthouse signal is a symptom. Match each one to the fix or diagnosis tool you'd reach for. Click an item on the left, then its match on the right. Press Check when done.
next/image preload and give it correct sizesLighthouse also flags accessibility, SEO, and best-practices issues almost for free: missing alt text, low contrast, missing meta tags, insecure requests. Those topics have their own lessons, but the audit surfaces them here and each one is worth fixing.
Two cadences: pre-launch deep pass and recurring gate
Section titled “Two cadences: pre-launch deep pass and recurring gate”The same tool, Lighthouse, does two jobs on two schedules: a one-off deep pass before launch, and a gate that runs forever after.
The pre-launch deep pass is the one-off.
Before ship, run PageSpeed Insights against the marketing page, the dashboard, and two or three other critical screens.
Route each finding through the chapter’s fix map: bundle bloat to the treemap, image problems to next/image, slow renders to the RSC waterfall, slow queries to the query work.
Ship, re-audit, and repeat until every surface clears the cheat-sheet targets.
There’s no traffic yet, so every report’s field section is empty and lab is the whole picture.
The recurring CI gate is the floor.
@lhci/cli runs on every pull request that touches UI or dependencies, asserting the budgets against the two surfaces.
It can’t make your app fast; it can only stop it from silently regressing past the budget as it grows.
Pair it with the post-launch verdict from Speed Insights and the chapter’s other recurring checks.
A third move unlocks after launch: calibration. Once real field data exists, recalibrate the CI budgets against that history, not against vanity scores. If the field consistently beats a lab budget, tighten it. If the field is worse than the lab predicted, your lab profile is too generous, so trust the field for prioritization. That is the first lesson’s rule, finally actionable.
Your team merges a pull request that adds a charting library to the dashboard. Two months later, that library has quietly pushed the dashboard’s bundle past its budget. Which Lighthouse posture was supposed to catch this at merge time?
@lhci/cli CI gate’s JS resource budget, which fails the PR when shipped JavaScript busts its capExternal resources
Section titled “External resources”These are the references that back this lesson: the LHCI docs you’ll consult while writing your config, and the source for why Lighthouse can’t measure INP.
The canonical reference for lighthouserc.json — the collect, assert, and upload blocks behind the gate.
Current version and changelog, where you check which Lighthouse release it bundles.
Why INP is field-only and TBT is just a lab proxy, this lesson's central correction, from web.dev.
The hosted Lighthouse plus CrUX overlay you run against the marketing page pre-launch.