Core Web Vitals Budget Allocation
Allocating Core Web Vitals budgets means shifting from reactive optimization to proactive resource partitioning: assigning each of Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS) a strict byte-and-time ceiling per route before code reaches production. This guide is part of the Defining Web Performance Budgets reference, and it details how to derive a ceiling from field data, decompose LCP into its sub-parts, partition the INP execution budget across the main thread, and gate the whole thing in CI so a regression is caught in a pull request rather than a support ticket.
The central idea is that a metric like LCP is never monolithic. It is the sum of network latency, resource load, and render delay, and you cannot manage what you have not decomposed. Allocation starts by breaking each metric into addressable sub-parts, assigning a millisecond or byte ceiling to each, and then enforcing those ceilings independently. A budget that reads "LCP under 2500 ms" is a wish; a budget that reads "TTFB under 800 ms, resource load under 1100 ms, render delay under 600 ms" is a contract you can hold each team to.
LCP Sub-Part Allocation
The 2500 ms LCP ceiling is an envelope, not a single timer. It splits into Time to First Byte, the resource-load window for the LCP element (image fetch and decode, or font swap), and the render delay before the element paints. Allocate a ms ceiling to each sub-part so a regression in one is isolated and attributable. The diagram below shows a representative split of a 2500 ms P75 budget on high-end mobile over 4G.
The reserve sizes are not arbitrary. TTFB is anchored to what your origin and CDN can sustain at P75, not the median, because the median hides the slow tail that decides whether the metric passes. If your field TTFB sits at 700 ms P75 for mid-range mobile on Fast 3G, an 800 ms reserve leaves almost no slack, and the honest move is to shrink the resource-load and render-delay ceilings rather than pretend the network is faster than it is. Resource load is where hero images and late-swapping fonts dominate; render delay is where render-blocking CSS and long tasks on the main thread hide. Because those three failure modes have different owners — infrastructure, media, and application code — three ceilings route each regression to the team that can actually fix it.
Prerequisites & Environment
Budget allocation depends on field data and a deterministic measurement harness. Have the following in place before deriving ceilings:
- Field data source — the CrUX API or a RUM provider that exposes route-level P75 for LCP, INP, and CLS over a rolling 28-day window, segmented by device class. Without segmentation you will average a fast desktop cohort into a slow mobile one and set a ceiling that protects nobody.
@lhci/cli>= 0.13 and Chrome >= 120 — for the synthetic gate, pinned inpackage-lock.jsonso assertions are reproducible across machines. A floating Chrome version silently shifts your lab numbers and turns a stable gate flaky.- A budget manifest location — a version-controlled
budget-manifest.jsonat the repository root, the single source of truth every team reads from. - CPU/network throttling alignment — CI must run
throttlingMethod: simulatewith a 4x CPU slowdown to match the mid-range mobile baseline, the most common source of lab-to-field divergence. If you gate mobile and desktop from one config you will mis-calibrate both; keep them separate as described in Mobile vs Desktop Budget Divergence.
Deriving Ceilings From the Field Distribution
A ceiling is a decision about the shape of a distribution, not a round number you like. Pull 28 days of field LCP for the route, look at the full histogram, and read off the percentile you gate on — P75 for the pass/fail line Google reports, P90 when the route is revenue-critical and you want to protect the slow tail as well. Then set the lab assertion tighter than the field percentile, because a simulated CI run is quieter and faster than a real handset on a congested network. A gap of 10 to 15 percent is a reasonable default: if field LCP is 2500 ms P75 for mid-range mobile on Fast 3G, assert around 2200 ms in the lab so that by the time the field number drifts up to 2500 ms, CI has already been red for weeks.
Choosing between P75 and P90 is a policy decision about how much of the slow tail you are willing to defend, and it has cost consequences: a P90 ceiling is materially harder to hold and forces more engineering time per route. Use P75 as the default and reserve P90 for checkout, sign-up, and other pages where the slow tail is the paying customer; the trade-off is worked through in Choosing Between P75 and P90 Budget Targets.
Configuration Reference
Derive the manifest by querying field P75 per route, then subtracting the baseline TTFB reserve from the total LCP target to expose the execution headroom available for resource load and render delay. The remaining headroom is partitioned across rendering, INP responsiveness, and layout-stability margin. The annotated manifest below is the authoritative per-route contract.
{
"route": "/",
"device_class": "high_end_mobile",
"lcp_target_ms": 2500,
"ttfb_reserve_ms": 800,
"lcp_resource_load_ms": 1100,
"lcp_render_delay_ms": 600,
"inp_target_ms": 200,
"inp_framework_ms": 80,
"inp_data_ms": 120,
"cls_target": 0.1,
"cls_reserved_slots": ["ad-top", "hero-media", "consent-banner"]
}
ttfb_reserve_ms is subtracted first because it is the floor you cannot optimize in the browser; what remains is the budget the front end actually controls. lcp_resource_load_ms bounds the LCP element's fetch and decode, and lcp_render_delay_ms bounds the gap between resource availability and paint. inp_framework_ms and inp_data_ms split the interaction budget at roughly 40 percent framework reconciliation and 60 percent data processing so heavy work never starves an interaction. cls_reserved_slots names the elements you have pre-sized so a late-loading advert or consent banner cannot shove content and blow the layout-stability ceiling.
Step-by-Step Implementation
-
Derive the baseline. Query field P75 per route and write the per-sub-part ceilings into the manifest.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://www.example.com/","formFactor":"PHONE"}' \ | npx json -a record.metrics.largest_contentful_paint.percentiles.p75Expected output: a single millisecond value (for example
2310) you compare against the 2500 ms ceiling. -
Enforce the LCP critical path. Identify the LCP candidate from a Lighthouse DOM snapshot, then elevate it above the default network queue and cap its decode budget at 800 ms with a font swap timeout under 100 ms.
<link rel="preload" as="image" href="/img/hero.avif" fetchpriority="high"> <img src="/img/hero.avif" fetchpriority="high" width="1200" height="600" alt="">When the route is image-heavy catalog or storefront traffic, apply the per-page-type method in How to Set Realistic LCP Budgets for E-commerce, and keep the hero within the byte ceiling set in Image & Media Weight Budgets.
-
Partition the INP execution budget. Cap main-thread blocking at 50 ms per event handler, yield to the main thread with
scheduler.yield()(or offload to a Web Worker), and flag interactions over 200 ms with aPerformanceObserver.// inp-observer.js new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.duration > 200) reportSlowInteraction(entry); }); }).observe({ type: 'event', buffered: true });Expected console output during a slow interaction: a single
reportSlowInteractioncall carryinginteractionIdandduration. For data-dense interfaces, the dashboard-specific method is in Calculating INP Thresholds for Interactive Dashboards. -
Commit the manifest so every subsequent budget change is a reviewable diff, and require a code owner on the manifest path so a ceiling cannot be loosened without sign-off.
Partitioning the INP Execution Budget
INP is the metric most often left un-budgeted because it feels like a runtime property rather than a resource you can divide. It is divisible. An interaction's latency is input delay (time before the handler runs), processing time (the handler plus any synchronous framework re-render), and presentation delay (the paint after the handler resolves). A 200 ms P75 INP budget on mid-range mobile on Fast 3G splits cleanly across those phases, and within processing you again split framework reconciliation from your own data work so no single handler monopolizes the main thread.
The 50 ms per-handler cap is the practical lever. It comes from the observation that the browser needs the main thread free to paint the next frame, so a handler that blocks for 84 ms of data work must yield partway through rather than run to completion. scheduler.yield() breaks a long task into chunks the scheduler can interleave with rendering; where the work is genuinely CPU-heavy — sorting a large table, parsing a payload — move it to a Web Worker so the main thread never sees it. Single-page apps have an extra failure mode here, because a soft navigation runs handler, data fetch, and re-render inside one interaction; that pattern is budgeted separately in Single-Page App Performance Budgets.
Threshold Calibration
Do not copy these ceilings untouched; derive each from your own field P75 and set the lab assertion 10 to 15 percent tighter to absorb the lab-to-field gap. The matrix below shows representative starting points across the device classes you gate. Hold a new threshold at warn for two consecutive weeks of green baselines before promoting it to error, so the gate earns trust before it blocks merges; the percentile method is detailed in Percentile-Based Threshold Tuning.
| Device class | Connection | LCP P75 (ms) | INP P75 (ms) | CLS P75 | Lab LCP assertion (ms) |
|---|---|---|---|---|---|
| High-end mobile | 4G / LTE | 2200 | 180 | 0.08 | 1950 |
| Mid-range mobile | Fast 3G | 3500 | 300 | 0.12 | 3100 |
| Low-end mobile | Slow 3G | 4500 | 400 | 0.15 | 4000 |
| Desktop | Cable / Fiber | 1500 | 120 | 0.05 | 1350 |
The promotion lifecycle keeps the gate honest. A freshly derived ceiling starts as a warning that annotates a pull request without blocking it; once two weeks of main builds stay green against that warning, you flip it to an error that fails the check and, through branch protection, blocks the merge. Skipping the warning window is the single most common reason teams abandon a budget gate: the first week is noisy, engineers learn to click past a red check, and the gate is dead.
CI Enforcement Snippet
Lighthouse CI gates LCP and CLS directly. INP requires real interactions, so gate Total Blocking Time as the synthetic proxy and validate true INP from RUM. This lighthouserc.json is copy-paste ready.
{
"ci": {
"collect": {
"numberOfRuns": 3,
"settings": {
"throttlingMethod": "simulate",
"throttling": { "cpuSlowdownMultiplier": 4 }
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 150000 }]
}
}
}
}
Run this on pull_request, route warnings to Slack, and require the check in branch protection. numberOfRuns is 3 rather than 1 because a single Lighthouse run is noisy enough to flip a marginal assertion; three runs and the median assertion smooth out most of the variance. Initial and per-route script ceilings that feed these assertions are specified under JavaScript Bundle Size Limits.
Troubleshooting & Edge Cases
- LCP passes in CI but fails in field — the lab decode budget assumes a faster CPU; widen the mid-range mobile throttle to 6x and re-derive the resource-load ceiling from field P75 rather than the lab number.
- No
inpassertion key in Lighthouse — expected; INP needs real interactions. Gatetotal-blocking-timesynthetically and validate INP from RUM at P75. - CLS regresses only on cached navigations — a service worker alters resource timing; validate offline fallback rendering against the CLS ceiling separately from first load.
- Manifest drift across teams — enforce schema validation on PR so a route cannot ship without explicit per-sub-part ceilings, and treat an unlisted route as a hard failure rather than a default pass.
- Render delay dominates a passing resource-load budget — audit render-blocking CSS and long tasks in the critical window; the resource arrived on time but the main thread was busy, so the fix lives in render delay, not resource load.
- Third-party script inflates LCP and INP together — defer it behind consent or
requestIdleCallback; cross-check the loading method against Third-Party Script Constraints. - The gate is green but the field number keeps climbing — your lab-to-field gap has widened, usually because real devices got slower relative to your CI runner; re-measure the gap quarterly and re-tighten the lab assertion.
Frequently Asked Questions
Why split LCP into sub-parts instead of gating one number?
A single 2500 ms ceiling tells you a regression happened but not where. Partitioning into TTFB, resource load, and render delay makes each regression attributable: a slow render delay points at render-blocking CSS or long tasks, while a slow resource-load points at the hero image or font. You manage what you decompose.
Can Lighthouse CI gate INP directly?
No. INP is measured from real user interactions across a session, which a synthetic run does not reproduce. Gate total-blocking-time as the lab proxy in CI and validate true INP from RUM at P75. For dashboards with heavy event handlers, see Calculating INP Thresholds for Interactive Dashboards.
How do I split the INP budget across framework work and business logic?
Allocate roughly 40 percent to framework reconciliation and 60 percent to data processing and DOM rendering, then keep any single event handler under 50 ms of main-thread blocking by yielding with scheduler.yield() or offloading to a Web Worker. On a 200 ms P75 budget for mid-range mobile on Fast 3G that is about 56 ms framework and 84 ms data.
Should I gate on P75 or P90?
Use P75 as the default because it matches the pass line Google reports and is achievable across most routes. Reserve P90 for revenue-critical pages such as checkout and sign-up, where the slow tail is the paying customer and worth the extra engineering cost. The trade-off is worked through in Choosing Between P75 and P90 Budget Targets.
How much tighter than field P75 should the lab assertion be?
Start at 10 to 15 percent tighter to absorb the lab-to-field gap, so a 2500 ms field P75 becomes roughly a 2200 ms lab assertion for mid-range mobile on Fast 3G. Re-measure the gap quarterly, because as real devices age relative to your CI runner the gap widens and a once-safe assertion drifts too loose.