Scheduling Nightly Lighthouse Runs
A pull-request gate only measures what a pull request changes. It never sees the marketing team swapping a tag-manager container at 9 p.m., the CDN edge config that shifted a cache header, or the A/B platform that started injecting an extra 80 KB of script into the hero. Those regressions land on production without touching your codebase, so no PR ever triggers Lighthouse against them. A scheduled nightly run closes that blind spot: it audits real production URLs on a fixed cron, stores every result on your history server, and alerts when a budget breaks. This how-to is part of the Continuous Performance Monitoring reference, and it assumes you already gate merges with Lighthouse CI on every pull request; here we wire the complementary out-of-band job that watches production while nobody is looking.
The nightly job is deliberately different from the PR gate. It runs against live URLs rather than a freshly built preview, it takes more samples per URL because it can afford the wall-clock time, and it fails loudly to a chat channel rather than blocking a merge that does not exist. The goal is early detection with a short, predictable latency, not zero-latency blocking.
The Nightly Pipeline
The whole job is one lhci autorun invocation wrapped in a scheduled GitHub Actions workflow. Cron fires the workflow at a quiet hour; the runner checks out the repo only to read lighthouserc.json; lhci collects several Lighthouse runs against each production URL, keeps the median, asserts it against the budget, and uploads every report to your Lighthouse CI server. If any assertion breaches, the job exits non-zero and a final step posts an alert.
Configuring the Run Against Production
Because the nightly job targets live URLs, collect.url points straight at production and there is no build or preview server to manage. Sample more aggressively than the PR gate — five runs per URL rather than three — and keep the list short so the whole matrix finishes in a few minutes. Use the desktop preset here for a stable, low-noise signal; the simulated broadband profile with no CPU throttle produces far less run-to-run variance than a throttled mobile profile, which matters when you want a clean nightly trend line.
{
"ci": {
"collect": {
"numberOfRuns": 5,
"url": [
"https://www.example.com/",
"https://www.example.com/products/",
"https://www.example.com/checkout/"
],
"settings": {
"preset": "desktop",
"throttlingMethod": "simulate",
"maxWaitForLoad": 45000
}
},
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 300 }],
"resource-summary:third-party:size": ["warn", { "maxNumericValue": 512000 }]
}
},
"upload": {
"target": "lhci",
"serverBaseUrl": "https://lhci.example.internal",
"token": "$LHCI_TOKEN"
}
}
}
Those ceilings are P75 desktop budgets: the 2500 ms largest-contentful-paint and 0.1 cumulative-layout-shift limits are evaluated at the 75th percentile of the five runs on the Lighthouse desktop preset (simulated broadband, no CPU throttle). If you also monitor a mobile profile, hold a separate, looser ceiling — around 4000 ms LCP at P75 on a mid-tier mobile device with simulated Fast 3G and 4x CPU throttle — because the two device classes are not comparable. The resource-summary:third-party:size assertion is deliberately a warn: it is your early-warning tripwire for the vendor-payload creep described in Third-Party Script Constraints, and you want a nightly comment about it, not a red build, until the ceiling has held for two weeks.
Why numberOfRuns Matters
Lighthouse is noisy. A single audit of a production URL can swing hundreds of milliseconds between runs purely from cold caches, ad-auction timing, and runner contention. numberOfRuns tells lhci to collect several audits and assert against the median, and the marginal noise reduction is steep at first, then flattens. Five runs is the sweet spot for a nightly job that has time to spare: it roughly quarters the run-to-run spread of a single run without doubling the cost of three.
Deciding whether a nightly delta is real noise or a genuine regression is its own discipline — the median stabilises variance but does not eliminate it, and the deeper techniques for separating signal from flakiness live in Statistical Noise and Flakiness Reduction. For the nightly job, five runs plus a median is enough to make the trend line legible.
The Full Workflow YAML
Here is the complete, copy-paste-ready workflow. The schedule trigger runs it at 02:00 UTC every day, and workflow_dispatch lets you fire it by hand to verify. Note that scheduled workflows always run on the default branch, so you do not need to guard the branch. The alert step runs only on failure, and the artifact upload runs always() so you can download the HTML reports even from a red run.
name: Nightly Lighthouse
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: nightly-lighthouse
cancel-in-progress: true
jobs:
nightly-lighthouse:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Run Lighthouse against production
run: npx lhci autorun
env:
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}
- name: Upload HTML reports
if: always()
uses: actions/upload-artifact@v4
with:
name: nightly-lighthouse-reports
path: .lighthouseci/
retention-days: 14
- name: Alert on breach
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
curl -sS -X POST -H "Content-Type: application/json" \
--data "{\"text\":\"Nightly Lighthouse budget breach on production. Report: ${RUN_URL}\"}" \
"$SLACK_WEBHOOK_URL"
Two details keep this robust. The concurrency group with cancel-in-progress guarantees a long-running audit never overlaps the next night's run if something hangs. The timeout-minutes: 20 cap means a stuck Chrome launch fails fast and still fires the alert, rather than burning a full six-hour job allowance. The upload target and token come from repository secrets; the serverBaseUrl and token in lighthouserc.json reference the same values so the reports land on your self-hosted history server.
What the Nightly Run Catches That PRs Miss
The value of the schedule is temporal coverage. The PR gate only fires when someone opens or updates a pull request, which in practice means daytime working hours. Anything that changes production without a code change — a tag-manager publish, a CMS content edit that ships a giant unoptimised image, an experiment platform that flips a flag — has no PR to trigger a gate. The nightly run is the net underneath all of it.
Your detection latency is exactly your cron cadence: a once-nightly run means a worst case of roughly 24 hours between a silent production change and the alert, and typically far less. If that window is too wide for a high-traffic checkout flow, tighten the schedule to 0 */6 * * * for a four-times-daily cadence — but weigh that against cost, covered below. The nightly cadence is usually the right default because it aligns with your baseline promotion rhythm and keeps the trend line one clean point per day.
Uploading and Alerting
Storing every nightly result on a history server is what turns isolated audits into a trend you can reason about. With upload.target set to lhci, each run pushes its full reports to the server, which builds a per-URL time series you can chart and diff. If you have not stood one up yet, follow Self-Hosting the Lighthouse CI Server; the nightly job is its highest-value producer of data because it writes one dependable point per URL per day.
The alert step in the workflow is intentionally minimal — a single webhook POST on failure(). That is enough to page a channel, but for production monitoring you usually want smarter routing: dedupe repeat breaches, distinguish a real regression from a one-night blip, and attach the metric delta. That logic belongs in Alerting on Synthetic Performance Drift, which consumes the very history this job writes. Keep the inline webhook as a floor so a breach is never silent, and layer richer alerting on top.
| Concern | PR gate | Nightly monitor |
|---|---|---|
| Trigger | pull_request event | cron schedule |
| Target | built preview | live production URLs |
| Runs per URL | 3 (median) | 5 (median) |
| On breach | blocks merge | posts alert |
| Detection latency | at review time | up to one cron interval |
| Catches vendor or CMS drift | no | yes |
Keeping the Nightly Job Cheap
A monitoring job that quietly burns your Actions minutes will get switched off, so engineer it to be cheap by design. Four levers keep the cost predictable. First, keep the URL list short and representative — three to five templates that cover your worst offenders, not every route. Second, use five runs, not ten; the variance chart above shows the seventh run barely moves the spread from 95 ms to 70 ms on the desktop preset at P75, so paying for more is waste. Third, cap the job with timeout-minutes and concurrency so a hung Chrome never runs away with an allowance. Fourth, resist the urge to over-schedule: a nightly cadence produces one clean data point per URL per day, which is exactly what baseline promotion and weekly reporting consume — sub-hourly runs multiply cost without improving the daily trend.
- name: Run Lighthouse against production
run: npx lhci autorun --collect.numberOfRuns=5
env:
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
LHCI_SERVER_BASE_URL: ${{ secrets.LHCI_SERVER_BASE_URL }}
A single nightly run over four URLs at five runs each finishes on ubuntu-latest in well under ten minutes, or about 300 billable minutes a month — trivial next to the cost of a checkout LCP regression sitting undetected on production for a business day.
Verification
Do not wait for tonight to find out the job works. Trigger it by hand with the workflow_dispatch you added, watch it complete, and confirm three things: the run finished green, the reports reached the server, and a deliberately broken budget actually alerts.
# Fire the nightly workflow manually and follow it to completion
gh workflow run "Nightly Lighthouse"
gh run watch
Confirm the reports landed on the history server by listing the project's most recent builds through the LHCI API:
curl -sS "https://lhci.example.internal/v1/projects" \
| jq -r '.[] | select(.name=="web-frontend") | .id' \
| xargs -I {} curl -sS "https://lhci.example.internal/v1/projects/{}/builds?limit=1" \
| jq -r '.[0] | "\(.branch) \(.hash) committedAt=\(.committedAt)"'
Finally, verify the alert path end to end by temporarily tightening one ceiling — for example set largest-contentful-paint to maxNumericValue: 1 — dispatching the job, and confirming the red run posts to your channel. A passing run ends with a zero exit and this tail in the job log:
Checking assertions against 3 URLs, 5 runs each...
All results processed!
Uploading median LHR of https://www.example.com/...success!
Done running Lighthouse!
A breach prints the failing assertion, exits non-zero, and fires the alert step:
✘ largest-contentful-paint failure for maxNumericValue assertion
expected: <=2500
found: 3140
1 result(s) failed
If the run is green but you know production regressed, check that collect.url points at the live origin and not a stale staging host — a nightly job auditing the wrong environment is the single most common silent failure.
Frequently Asked Questions
Should the nightly job block anything the way a PR gate does?
No. There is no merge to block, so a breach should alert, not gate. Keep hard error assertions for the metrics you trust and route the failure to a chat channel. Use the per-pull-request gate for merge-blocking and the nightly run for out-of-band detection.
Why five runs at night when the PR gate uses three?
The nightly job has spare wall-clock time and wants the cleanest possible trend point. Moving from three to five runs cuts the median LCP spread from about 180 ms to 95 ms on the desktop preset at P75, which sharpens breach detection. Seven runs barely helps, so five is the cost-effective setting.
What cron cadence should I use?
Start with once nightly (0 2 * * *) at a low-traffic hour. Your worst-case detection latency equals the interval, so a daily run means up to 24 hours. Tighten to every six hours only for a critical flow, and weigh the extra Actions minutes against the risk.
How does the nightly run catch a regression with no code change?
It audits live production URLs rather than a built preview, so it sees whatever is actually served — a tag-manager publish, a heavy CMS image, or an experiment flag. Those never open a pull request, so only a scheduled audit against production detects them. Vendor payload creep specifically is bounded by Third-Party Script Constraints.