How to Derive Performance Budgets from Core Web Vitals Thresholds

This guide turns the thresholds in Understanding Core Web Vitals Thresholds into budgets a build can enforce, as part of Core Web Vitals & Measurement. The problem it solves: field metrics are the goal, but they are lagging and noisy. By the time CrUX shows a regression, the change that caused it shipped weeks ago. Budgets on the inputs — bytes, requests, main-thread time — catch regressions in the pull request that introduces them.

The difficulty is that thresholds are about time on users' devices, while budgets are about artefacts your build produces. Bridging them requires a model: for your mobile p75 conditions, how many bytes and how many milliseconds of work fit inside 2.5s LCP and 200ms INP? The answer is template-specific, and deriving it is a one-day exercise that pays back on every release.

From threshold to enforced budget Five steps from the Core Web Vitals threshold through field conditions and a timing model to resource budgets and CI enforcement. From threshold to enforced budget Threshold LCP 2.5s at p75 Field conditions p75 RTT + bandwidth Timing model phases of the path Resource budgets KB CI gate fail the PR

Rapid Diagnosis: Do You Need Derived Budgets?

  • Regressions are found in CrUX, not in review. If your team discovers performance regressions from the monthly report, you need input budgets.
  • Budgets exist but nobody believes them. A "200KB JavaScript" budget copied from a blog post, unconnected to your conditions, gets raised every time it fails.
  • Templates differ widely. A blog post and a product configurator cannot share one budget; derived budgets are per template.
  • You cannot say which resource "owns" LCP time. Without a timing model, you cannot argue whether 40KB more CSS is acceptable.

Root Cause Analysis: Why Arbitrary Budgets Fail

1. They ignore your users' conditions. 170KB of compressed JavaScript is fine for desktop users on fibre and fatal for low-end Android users on congested 4G. A budget without a target device and network is a guess.

2. They budget the wrong resource. Total page weight correlates poorly with LCP. What matters is the bytes and round trips on the critical path to the LCP element, plus main-thread time before it can paint.

3. They treat compressed size as cost. JavaScript's cost is mostly parse, compile and execute — proportional to uncompressed size and code complexity, not gzip size. Why compressed size is the wrong budget explores this.

4. They have no headroom. A budget equal to the threshold leaves nothing for variance, third parties added later, or a slow day on the CDN.

Step-by-Step Derivation

1. Pin down p75 field conditions

From your RUM, take the p75 round-trip time and effective downlink for mobile visits to the template (Network Information API rtt and downlink where available, or Navigation Timing deltas). Also take the device-class mix to choose a CPU throttling factor.

javascript
// Field conditions from RUM rows (Chromium only for rtt/downlink).
const p = (arr, q) => arr.sort((a, b) => a - b)[Math.floor(arr.length * q)];
const rtt = p(rows.map((r) => r.rtt), 0.75);              // e.g. 175 ms
const downlinkMbps = p(rows.map((r) => r.downlink), 0.25); // slow quartile, e.g. 3.2 Mbps
// trade-off: navigator.connection values are coarse (rounded, capped) and
// Chromium-only. Cross-check with TTFB and resource timings from all browsers.

Expected outcome: a concrete profile such as "175ms RTT, 3.2Mbps, 4x CPU" that becomes your lab throttling setting too.

2. Model the critical path to LCP

Count the round trips and bytes that must complete before the LCP element paints, and assign each phase a share of the 2.5s budget minus headroom.

PhaseBudget (of 2.1s target)What it implies at 175ms RTT, 3.2Mbps
Connection + TTFB600ms≤ 3 RTT setup + server ≤ 75ms
Render-blocking CSS300ms≤ 1 request, ≤ 40KB compressed
LCP image load700ms≤ ~150KB at 3.2Mbps after contention
JS before paint (if any)300ms≤ ~100KB uncompressed on the path
Render delay200mslayout + paint on 4x CPU

The target is 2.1s, not 2.5s: roughly 15% headroom for variance.

Budgeted LCP critical path at p75 mobile conditions Timeline allocating a 2.1 second LCP target across connection, CSS, image load, script and render phases, with 2.5 second threshold marked. Budgeted LCP critical path at p75 mobile conditions Budget connect + TTFB CSS LCP image JS render 0ms 500ms 1000ms 1500ms 2000ms 2500ms 2.5s threshold The 400ms gap before the threshold is headroom for variance and future third parties.

3. Derive the INP and CLS budgets

INP budgets are main-thread budgets: no task over 50ms during the interaction window, handler processing under 100ms on the throttled profile, and a cap on total blocking time during load (for example 300ms TBT at 4x CPU) so early interactions are not stuck behind startup work. CLS budgets are structural: every image and embed has dimensions, no late-inserted content above existing content, and a lab CLS of 0.05 or less for scripted loads.

4. Encode budgets per template and enforce them

json
[
  {
    "path": "/products/*",
    "timings": [{ "metric": "largest-contentful-paint", "budget": 2100 },
                { "metric": "total-blocking-time", "budget": 300 },
                { "metric": "cumulative-layout-shift", "budget": 0.05 }],
    "resourceSizes": [{ "resourceType": "script", "budget": 170 },
                      { "resourceType": "stylesheet", "budget": 40 },
                      { "resourceType": "image", "budget": 300 }],
    "resourceCounts": [{ "resourceType": "third-party", "budget": 8 }]
  }
]

Use this budget.json with Lighthouse CI, and keep a separate bundle-size check (such as size-limit) that measures uncompressed JavaScript per entry point, because Lighthouse's resource sizes are transfer sizes.

javascript
// lighthouserc.js — use the budget file on representative URLs per template.
module.exports = { ci: {
  collect: { url: ['http://localhost:4173/products/sample', 'http://localhost:4173/blog/sample'],
             settings: { throttlingMethod: 'devtools', throttling: { rttMs: 175, throughputKbps: 3200, cpuSlowdownMultiplier: 4 } } },
  assert: { budgetsFile: './budget.json' },
} };
// trade-off: devtools throttling is more realistic than simulated throttling
// but noisier; run 3-5 times and assert on the median to avoid flaky builds.

Expected outcome: a pull request that adds 60KB of script to the product template fails before merge, with a message tied to a specific, defensible number.

Three layers of budget, three speeds of feedback Layered view of bundle-size checks, lab Lighthouse budgets and field RUM targets, from fastest to slowest feedback. Three layers of budget, three speeds of feedback Bundle size check every commit, seconds — uncompressed JS per entry point Lab Lighthouse budget every PR, minutes — timings and resource sizes per template Field RUM target daily, p75 per template — the number that actually ships

Verification

Validate the model before trusting it: take a page that currently passes in the field and one that fails, and check that the lab budget run passes the first and fails the second. If both pass, your lab conditions are too generous — tighten throttling until the lab separates them. Revisit the derivation quarterly, or whenever the device mix in your RUM shifts noticeably.

Worked Example: A Product Template

A retailer's product template fails LCP on mobile at 2.9s p75. RUM gives p75 mobile conditions of 160ms RTT and 4Mbps effective downlink, and the device mix suggests 4x CPU throttling. The critical path today is: HTML (TTFB 480ms), two blocking stylesheets totalling 95KB compressed, a web font for the product title, 240KB of JavaScript before the gallery component renders the hero, and a 210KB hero image.

Working backwards from a 2.1s target: connection and TTFB keep 600ms. The two stylesheets at 4Mbps after contention take roughly 350ms — over the 300ms allocation, so the budget becomes "one stylesheet, ≤ 40KB, critical CSS inlined". The JavaScript must go: a gallery that renders the hero only after 240KB of script cannot meet any reasonable budget, so the hero <img> moves into server HTML and the gallery enhances it afterwards. That frees the 300ms JS allocation entirely. The 210KB image at 4Mbps is ~420ms of transfer plus contention; AVIF at the right sizes brings it to 90KB, comfortably inside the 700ms image allocation.

The resulting budget file for the template reads: stylesheet ≤ 40KB, script on the LCP path 0KB, LCP image ≤ 120KB, third-party requests before LCP 0. None of these numbers came from a blog post; each can be defended by pointing at the timing model, and each failed check points at a specific phase of LCP.

Keeping Budgets Credible Over Time

Budgets lose authority when they are raised casually. Require that any budget increase comes with a field justification — evidence that the template still has headroom at p75 — and an owner. Track "budget headroom remaining" per template as a visible number; when headroom approaches zero, new features must pay for themselves by removing weight elsewhere. That conversation is the real value of a budget: it makes performance a design constraint rather than a cleanup task.

FAQ

Should budgets be per page or per template?

Per template. Pages built from the same template share code, CSS and structure, so their budgets and their fixes are the same. Per-page budgets multiply maintenance without adding information, except for a few unusual pages that deserve their own entry.

How much headroom is enough?

Start with 15–20% below each threshold for timing budgets. Increase it for templates with heavy third-party content, which varies more, and reduce it only if your RUM shows the template is consistently well inside the threshold at p75.