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.

LCP sub-part allocation timeline A 2500 ms LCP budget partitioned into a TTFB segment of 800 ms, a resource-load segment of 1100 ms, and a render-delay segment of 600 ms, each with its own ceiling. time to LCP paint (ms) TTFB ≤ 800 ms resource load ≤ 1100 ms (fetch + decode) render delay ≤ 600 ms 0 800 1900 2500
Partition the LCP envelope so each sub-part has an independent ceiling; a render-delay regression is then attributable without re-measuring the whole metric.

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 in package-lock.json so 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.json at the repository root, the single source of truth every team reads from.
  • CPU/network throttling alignment — CI must run throttlingMethod: simulate with 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.

Deriving the lab ceiling from the field distribution A frequency histogram of 28 days of field LCP with vertical lines at the field P75 of 2500 milliseconds, the field P90 of 3400 milliseconds, and the lab ceiling of 2200 milliseconds set below P75. 1000 2000 3000 4000 field LCP (ms) — mid-range mobile on Fast 3G, 28-day window lab ceiling 2200 ms (P75 minus 12%) field P75 2500 ms field P90 3400 ms
Read the ceiling off the distribution: gate the lab assertion below field P75 so CI turns red before the field number crosses the pass line.

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

  1. 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.p75

    Expected output: a single millisecond value (for example 2310) you compare against the 2500 ms ceiling.

  2. 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.

  3. 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 a PerformanceObserver.

    // 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 reportSlowInteraction call carrying interactionId and duration. For data-dense interfaces, the dashboard-specific method is in Calculating INP Thresholds for Interactive Dashboards.

  4. 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.

INP execution budget partition A horizontal bar representing a 200 millisecond INP budget divided into input delay, framework reconciliation, data processing, and presentation delay segments. input 30 ms framework 56 ms (40%) data processing 84 ms (60%) present 30 ms 0 ms 200 ms no single event handler may exceed 50 ms of main-thread blocking
The processing window is the part you control; splitting it 40/60 between framework and data keeps either side from consuming the whole interaction budget.

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.

Threshold promotion lifecycle A left to right flow showing a threshold set at warn, promoted to error after two weeks of green baselines, then enforced as a merge block by branch protection. Set at warn annotates the PR Promote to error after 2 weeks green fails the check Blocks merge via branch protection
A ceiling earns its enforcement: warn while it is noisy, promote to error once main builds stay green, then let branch protection block the merge.

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 inp assertion key in Lighthouse — expected; INP needs real interactions. Gate total-blocking-time synthetically 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.