Separate Mobile and Desktop Lighthouse Budgets
Running Lighthouse with one config and asserting one set of thresholds forces a choice between a budget that is unrealistic on desktop and one that is too lax for mobile. The fix is two lighthouserc files in the same repository, each with its own throttling profile and assertion block, fanned through a CI matrix. This guide, part of the Mobile vs Desktop Budget Divergence reference, gives the concrete two-config setup, the exact assertion JSON for each form factor, and the matrix job that gates both.
The two configs are structurally identical and differ only in preset, the throttling block, and a handful of numeric ceilings. Keeping them side by side makes the divergence reviewable in a single diff, and it lets a reviewer see at a glance that a change to the mobile script ceiling did not silently loosen desktop. Every number in this guide is a P75 target — the value the 75th-percentile visit on that device class and connection must beat — because that is the percentile Google reports Core Web Vitals against. If you are still deciding whether to gate at P75 or the stricter P90, read Choosing Between P75 and P90 Budget Targets before you copy these ceilings.
Why One Budget Cannot Cover Both Form Factors
A mid-range mobile device on emulated 4G with a 4x CPU slowdown and an unthrottled desktop on a cable connection are two different machines running the same code. The mobile CPU parses and executes JavaScript roughly four times slower, so a script payload that costs 120 ms of main-thread work on desktop costs closer to 480 ms on mobile. The network is slower too, but the CPU gap is what dominates interaction metrics. That asymmetry is why timing budgets loosen on mobile while byte budgets tighten: you give mobile more wall-clock room for the same page, but you refuse to let it download the extra script that would blow the interaction budget.
If you asserted a single LCP ceiling of 2000 ms across both, the mobile job would fail on almost every real page even when the code is healthy, training the team to ignore the check. If you asserted 2500 ms across both, desktop would pass while shipping a genuinely slow experience to the device class that has the most headroom. Neither single number is honest. Two form-factor budgets keep each gate meaningful.
Threshold Table Per Form Factor
The table below is the per-form-factor budget these configs encode, calibrated for the P75 user on each profile. Timings loosen on mobile because the network and CPU are throttled; the script byte ceiling tightens because each parsed byte costs more main-thread time on a 4x-throttled CPU. INP and CLS stay the same across both because a layout shift or a slow event handler is equally unacceptable on either device — those are correctness budgets, not hardware-scaled ones.
| Assertion | Mobile (4G, 4x CPU, P75) | Desktop (Cable, 1x CPU, P75) |
|---|---|---|
metric-lcp |
2500 ms | 2000 ms |
metric-inp |
200 ms | 200 ms |
metric-cls |
0.1 | 0.1 |
total-blocking-time |
200 ms | 150 ms |
resource-summary:script:size |
150000 B | 250000 B |
resource-summary:image:size |
400000 B | 600000 B |
The image row is included because hero art is usually the LCP element, and on the mobile profile a 600 KB hero would push LCP past the 2500 ms P75 ceiling on a 4G connection. Capping images at 400 KB on mobile is what makes the 2500 ms timing budget achievable rather than aspirational.
Diagnostic Steps
-
Confirm each profile produces distinct timings by running both configs against the same URL.
npx lhci collect --config=./lighthouserc-mobile.json npx lhci collect --config=./lighthouserc-desktop.jsonExpected output: two
.lighthouseci/report sets; the mobile LCP should be meaningfully higher than desktop for the same page. If they match, the mobile throttling is not being applied. -
Verify the throttling is active by reading the metric from each report:
npx lhci openExpected output: the report viewer shows the emulated form factor and CPU/network throttling under "Runtime settings" — confirm
4x slowdownon the mobile run andNo throttling(CPU) on desktop. -
Diff the two medians so you know the real headroom before you set ceilings:
npx lhci collect --config=./lighthouserc-mobile.json cat .lighthouseci/*.json | jq '.audits."largest-contentful-paint".numericValue'Expected output: one numeric LCP per run in milliseconds. Sort them and take the middle value — that median is your current P75-ish mobile figure to budget against, not the best single run.
Implementation
Create both files at the repository root. lighthouserc-mobile.json:
{
"ci": {
"collect": {
"url": ["https://staging.example.com/"],
"numberOfRuns": 5,
"settings": {
"preset": "mobile",
"throttlingMethod": "simulate",
"throttling": { "cpuSlowdownMultiplier": 4, "rttMs": 150, "throughputKbps": 1600 }
}
},
"assert": {
"assertions": {
"metric-lcp": ["error", { "maxNumericValue": 2500 }],
"metric-inp": ["error", { "maxNumericValue": 200 }],
"metric-cls": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 150000 }],
"resource-summary:image:size": ["error", { "maxNumericValue": 400000 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
lighthouserc-desktop.json:
{
"ci": {
"collect": {
"url": ["https://staging.example.com/"],
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"throttlingMethod": "simulate",
"throttling": { "cpuSlowdownMultiplier": 1, "rttMs": 40, "throughputKbps": 10000 }
}
},
"assert": {
"assertions": {
"metric-lcp": ["error", { "maxNumericValue": 2000 }],
"metric-inp": ["error", { "maxNumericValue": 200 }],
"metric-cls": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 150 }],
"resource-summary:script:size": ["warn", { "maxNumericValue": 250000 }],
"resource-summary:image:size": ["warn", { "maxNumericValue": 600000 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}
The mobile config uses numberOfRuns: 5 because 4x throttling amplifies single-sample noise, and gates everything as error; desktop runs three times and treats the looser byte and TBT limits as warn so minor desktop swings do not block merges. The preset value does most of the heavy lifting — mobile sets a 360x640 Moto-G-class viewport and enables CPU throttling, while desktop sets a 1350x940 viewport with CPU throttling off — but pinning the throttling block explicitly means the ceilings do not silently shift when a Lighthouse minor release retunes its preset defaults. If your script ceilings feel arbitrary, derive them from real route payloads the way Enforcing Per-Route JavaScript Budgets describes, then subtract the headroom you want to reserve.
Sharing Common Assertions Without Duplication
Two files means two places to update a shared ceiling like CLS. When that duplication starts causing drift, factor the common assertions into a base file and extends it, overriding only the form-factor-specific numbers. lighthouserc-base.json:
{
"ci": {
"assert": {
"assertions": {
"metric-cls": ["error", { "maxNumericValue": 0.1 }],
"metric-inp": ["error", { "maxNumericValue": 200 }]
}
}
}
}
Then the mobile file inherits it and adds only what diverges:
{
"extends": "./lighthouserc-base.json",
"ci": {
"collect": {
"url": ["https://staging.example.com/"],
"numberOfRuns": 5,
"settings": { "preset": "mobile", "throttlingMethod": "simulate" }
},
"assert": {
"assertions": {
"metric-lcp": ["error", { "maxNumericValue": 2500 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 150000 }]
}
}
}
}
Now CLS and INP live in one place and cannot drift apart between form factors, while LCP and the byte caps stay per-file where they genuinely differ. Keep the divergent numbers few and obvious; the value of two configs evaporates if a reviewer has to open three files to reason about a single ceiling.
CI Gating Assertion
This matrix job runs both configs in parallel and surfaces a status check per form factor. The exact assertion blocks above are what fail each job.
name: Lighthouse Budget Gate
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
device: [mobile, desktop]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
- name: Lighthouse CI (${{ matrix.device }})
run: npx lhci autorun --config=./lighthouserc-${{ matrix.device }}.json
fail-fast: false keeps the desktop job running even when mobile fails, so one run reports both verdicts. Require both lighthouse (mobile) and lighthouse (desktop) checks in branch protection so neither form factor can be merged past.
Verification
Run both configs locally and confirm each enforces its own ceiling:
npx lhci autorun --config=./lighthouserc-mobile.json
npx lhci autorun --config=./lighthouserc-desktop.json
Each prints an assertion summary; a passing mobile run shows metric-lcp under 2500 ms and a passing desktop run shows it under 2000 ms. Temporarily lower the mobile metric-lcp ceiling to 1000 and re-run to confirm the gate exits non-zero on that config only — that proves the two budgets are independent. For the CPU-multiplier calibration that makes these throttling numbers match your runner, see Device & Network Emulation Weighting; GitHub-hosted runners vary enough between jobs that an uncalibrated cpuSlowdownMultiplier: 4 can under- or over-throttle by a full grade, and Calibrating CPU Throttling for CI Runners shows how to pin it with a benchmark step.
Frequently Asked Questions
Can I keep both budgets in one lighthouserc file instead of two?
A single config applies one set of collection settings and one assertion block per run, so it cannot encode two different throttling profiles and two threshold sets cleanly. Two files fanned through a matrix keep each form factor's settings explicit and make the divergence reviewable in one diff. See Mobile vs Desktop Budget Divergence for the full rationale.
Why does the mobile config run more times than desktop?
The 4x CPU slowdown on the mobile profile amplifies single-sample variance, so five runs are needed for a stable median; the unthrottled desktop profile is quieter and three runs suffice. Lighthouse keeps the median report, so odd counts avoid tie-breaking.
Why is the mobile script budget tighter than desktop when mobile timings are looser?
Because the mobile CPU parses and executes each script byte about four times slower under the 4x slowdown, so the same payload costs far more main-thread time and pushes total blocking time and INP past their P75 ceilings. Loosening the timing budget gives mobile room for the slower network, but the byte cap has to tighten to protect interaction on the slower CPU.
Should INP and CLS ever differ between the two configs?
Usually no. A layout shift or a 300 ms event handler is an equally bad experience on either device, so both are correctness budgets rather than hardware-scaled ones and stay at 200 ms and 0.1 at P75 across both form factors. Only the timing and byte budgets that hardware genuinely changes should diverge.
How do I stop the shared thresholds from drifting apart between the two files?
Factor the common assertions such as CLS and INP into a lighthouserc-base.json and have each form-factor file extends it, overriding only LCP and the byte caps that actually differ. That keeps a single source of truth for the shared numbers while the divergent ceilings stay per-file and obvious in review.