Defining Web Performance Budgets
A performance budget is an engineering contract, not an aspiration. It establishes immutable ceilings on latency, payload size, and rendering metrics that are enforced before code reaches production, the same way a type checker or a security scanner is. Aspirational targets drift; contracts gate merges. This budget-definition reference is the front half of a larger practice whose enforcement arm is Lighthouse CI & WebPageTest Integration and whose tuning arm is Threshold Calibration & Baseline Management. Here we cover the full operational lifecycle: codifying budgets as version-controlled schema, calibrating thresholds against device class and connection profile, enforcing asset-level constraints in CI, wiring observability that closes the loop with field data, and defining the failure modes and escalation paths that make the gate survivable for the team.
The discipline splits into seven coupled concerns, each with its own detailed reference: per-metric allocation across LCP, INP, and CLS; JavaScript payload limits; the divergence between mobile and desktop ceilings; third-party script governance; image and media weight; web font delivery; and the special case of client-rendered single-page apps. Get the schema and calibration wrong at the top and every section below inherits the noise. This page is the authoritative top-level spec; the named sections drill into each constraint. Read it start to finish once, then use it as the index you return to when a specific ceiling needs a decision.
Architecture Overview
A budget system is a closed loop. A version-controlled schema feeds a tool chain that measures every build; the CI pipeline asserts against the schema and gates the merge; observability compares shipped builds against field telemetry and feeds recalibration back into the schema. Each arrow in that loop is a place a regression can leak in if the handoff is lossy, so the value of the system is only as high as the fidelity of its weakest edge. The diagram below shows how the four layers connect.
The loop only works if each edge is deterministic. A schema that lists a threshold without a percentile or a device profile hands the tool chain an ambiguous target; a tool chain that measures under different throttling than production produces a gate that fires on lab artifacts; observability that cannot tie a field regression back to a Git SHA breaks the recalibration edge entirely. The rest of this reference specifies each edge tightly enough that the loop stays closed.
Budget Definition as Version-Controlled Schema
Budgets must be codified as version-controlled configuration, not tribal knowledge held in a wiki. A strict YAML or JSON schema that maps directly to your telemetry pipeline turns every threshold change into a reviewable diff, so loosening a ceiling requires the same scrutiny as changing an API contract. The schema correlates P75 field baselines with lab-derived synthetic targets and applies a controlled delta tolerance to absorb environmental variance between the runner and real devices. When Core Web Vitals Budget Allocation is treated as a hard constraint rather than a guideline, the boundary between shipping velocity and user-experience regression becomes explicit and reviewable.
The schema should carry one block per device profile, because a single flat list of numbers cannot express that mid-range mobile on Fast 3G and a desktop on cable are different contracts. Encode the throttling assumptions alongside the thresholds so the file is self-documenting and the CI runner can be configured directly from it.
# performance-budget.yaml
version: "1.1"
environment: "production"
tolerance:
lab_to_field_delta: "15%"
percentile: "p75"
min_runs_for_significance: 5
profiles:
mid_range_mobile:
device: "Moto G Power class"
cpu_slowdown: 4
network: "Fast 3G"
thresholds:
lcp_p75: 3500 # ms, Largest Contentful Paint ceiling
lcp_p90: 4100 # ms, tail-latency guard
inp_p75: 300 # ms, Interaction to Next Paint
cls_p75: 0.12 # unitless, Cumulative Layout Shift
ttfb_p75: 1100 # ms, server + network reserve
initial_js_gzipped: 150000 # bytes, initial-route script
initial_css_gzipped: 50000 # bytes, critical + deferred
image_weight: 900000 # bytes, above-the-fold media
font_payload: 40000 # bytes, WOFF2 subset
third_party_transfer: 80000 # bytes, vendor budget
desktop:
device: "reference laptop"
cpu_slowdown: 1
network: "Cable"
thresholds:
lcp_p75: 1500
lcp_p90: 1900
inp_p75: 120
cls_p75: 0.05
ttfb_p75: 500
initial_js_gzipped: 200000
initial_css_gzipped: 60000
image_weight: 1200000
font_payload: 50000
third_party_transfer: 100000
Enforce schema validation during PR creation. Reject any configuration that omits a percentile definition, lacks explicit tolerance routing, or leaves a device profile without its throttling assumptions, so every budget change undergoes architectural review before it merges. A useful guard is a lint rule that fails the pull request if any thresholds key is missing its percentile suffix, because a bare lcp field is the single most common way a device-blind ceiling slips into the schema. The schema is the contract; the rest of this reference is its enforcement.
Metric Selection and Threshold Matrix
A single global threshold fails in production. Network latency and CPU constraints force Mobile vs Desktop Budget Divergence, because a desktop-optimized ceiling silently masks mobile regressions. Calibrate P75 and P90 against device class, connection profile, and geographic routing. Weight synthetic lab data (Lighthouse, WebPageTest) at roughly 60% and anchor the remaining 40% in CrUX field data; the hybrid prevents over-fitting to idealized lab conditions while keeping the CI signal actionable.
| Device class | Connection | LCP P75 / P90 (ms) | INP P75 / P90 (ms) | CLS P75 / P90 | TTFB P75 (ms) | Payload P75 (KB) |
|---|---|---|---|---|---|---|
| High-end mobile | 4G / LTE | 2200 / 2600 | 180 / 230 | 0.08 / 0.11 | 700 | 170 |
| Mid-range mobile | Fast 3G | 3500 / 4100 | 300 / 380 | 0.12 / 0.16 | 1100 | 150 |
| Low-end mobile | Slow 3G | 4800 / 5600 | 420 / 520 | 0.15 / 0.20 | 1500 | 130 |
| Desktop | Cable / Fiber | 1500 / 1900 | 120 / 160 | 0.05 / 0.08 | 500 | 200 |
Implement connection-aware routing in the test harness: throttle CPU to 4x slowdown and network to Fast 3G for the mid-range mobile baseline, and fail builds when P90 metrics exceed the calibrated matrix by more than 10%. Derive these numbers from your own field data rather than copying them; the percentile methodology is the subject of Percentile-Based Threshold Tuning. The chart below reads the LCP P75 column against the 2500 ms "good" line that Chrome uses for its field assessment, so you can see at a glance which profiles have headroom and which sit against the wall.
The chart makes the design tension concrete: the slower the device profile, the less byte headroom you have before LCP P75 blows past the good line, which is why the mid-range mobile initial-script ceiling of 150 KB gzipped is stricter than the 200 KB desktop ceiling even though the desktop ships a richer page. Budgets are not uniform generosity; they are inversely proportional to the hardware you are targeting.
Choosing the Percentile
Every threshold in the matrix carries a percentile, and the choice between P75 and P90 is a policy decision, not a formatting detail. P75 is the industry default because it is the percentile Chrome uses to classify a page as good, needs-improvement, or poor in field data, so anchoring your budget to P75 keeps your internal signal aligned with the public assessment. A P75 LCP of 3500 ms for mid-range mobile on Fast 3G means three quarters of that population saw the largest content paint within 3500 ms; the slowest quarter is not directly governed by that number.
P90 exists to govern that slow tail. A page can hold a healthy P75 and still deliver a miserable experience to the tenth of users on constrained devices or congested networks, and those users are disproportionately likely to be on the low-end mobile profile where every regression lands hardest. Set a P90 ceiling alongside P75 when the tail carries real revenue — checkout, sign-in, and any flow where a slow session is an abandoned session. The trade-off is variance: P90 is noisier than P75 because it is estimated from fewer samples, so a P90 gate needs more collections and a wider tolerance band to stay stable. The full decision procedure, including how many samples you need before a P90 estimate is trustworthy, is worked through in Choosing Between P75 and P90 Budget Targets. A practical default is to gate on P75 at error level and track P90 at warning level, promoting P90 to a hard gate only on the two or three flows where tail latency is a business risk.
The Seven Coupled Budget Domains
The top-level number people quote is a rendering metric, but you cannot gate a rendering metric directly in a bundler. You gate the asset-level inputs that produce it, and those inputs partition into seven domains. Each domain owns a slice of the critical path, each has a distinct measurement surface, and each has its own reference page with worked ceilings. Treat this section as the map from the outcome you care about to the constraint you can actually enforce.
Core Web Vitals is the outcome layer, splitting the top-level field score into the per-metric ceilings covered under Core Web Vitals Budget Allocation. JavaScript is the largest lever on INP and often on LCP, capped through JavaScript Bundle Size Limits. Device divergence is a cross-cutting concern that reshapes every other ceiling. Images and fonts are byte-heavy render inputs governed by Image & Media Weight Budgets and Web Font Performance Budgets. Third-party scripts are the payloads you do not author but still ship, fenced by Third-Party Script Constraints. The seventh domain is newer: client-rendered apps where navigation happens without a document request, which need the soft-navigation and route-change ceilings described in Single-Page App Performance Budgets. A budget that omits the SPA case will pass the initial load and then silently degrade on every in-app transition.
Asset-Level Constraints and Implementation
Byte budgets must be enforced across the entire critical rendering path. Main-thread blocking is contained by enforcing the initial-route script ceiling of 150 KB gzipped for mid-range mobile on Fast 3G at P75 alongside route-based code-splitting; the initial route payload should stay under that ceiling, and secondary chunks defer through import(). Heavy visual routes layer on the 900 KB above-the-fold media ceiling from Image & Media Weight Budgets for the same mid-range mobile P75 profile, while text rendering is governed by Web Font Performance Budgets — subset to the needed glyphs, ship WOFF2, and cap total font payload near 40 KB for that profile.
Byte ceilings are easiest to enforce at build time, before anything reaches a browser, because a bundler knows the compressed size of every chunk deterministically. A size-limit manifest turns each ceiling into a named check that fails the build the moment a chunk crosses it.
[
{ "name": "initial route (mid-range mobile P75)", "path": "dist/assets/index-*.js", "limit": "150 KB" },
{ "name": "route: /checkout", "path": "dist/assets/checkout-*.js", "limit": "45 KB" },
{ "name": "route: /dashboard", "path": "dist/assets/dashboard-*.js", "limit": "60 KB" },
{ "name": "critical CSS", "path": "dist/assets/critical-*.css", "limit": "14 KB" },
{ "name": "web font subset", "path": "dist/assets/inter-subset-*.woff2", "limit": "40 KB" }
]
Uncontrolled vendor payloads are the most common silent budget breach, so every integration declares a maximum execution window and network budget under Third-Party Script Constraints, enforced through Content Security Policy, dynamic loading guards, and fallback timeouts. A vendor tag that stalls does not just add its own bytes; it holds the main thread and pushes INP past the 300 ms P75 ceiling for mid-range mobile on Fast 3G. The loader below enforces a hard timeout and routes failure to graceful degradation rather than letting a stalled vendor block the main thread.
// vendor-loader.js — enforce a per-vendor execution window
const VENDOR_TIMEOUT_MS = 4000;
function loadVendorScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
const timeout = setTimeout(() => {
script.remove();
reject(new Error(`Vendor script exceeded ${VENDOR_TIMEOUT_MS}ms execution window`));
}, VENDOR_TIMEOUT_MS);
script.onload = () => { clearTimeout(timeout); resolve(); };
script.onerror = () => { clearTimeout(timeout); reject(new Error('Vendor script failed to load')); };
document.head.appendChild(script);
});
}
Cap inlined critical CSS at 14 KB for every profile, apply font-display: swap (or optional for LCP text), and route any vendor failure to a degraded but functional fallback. These ceilings are the values your CI gate asserts, and because they are byte-exact they produce almost no runner noise — the flakiness lives in the timing metrics, not the payload metrics.
CI/CD Gating Integration
Budget validation is integrated into the PR pipeline as a deterministic synthetic check, gated in three stages: lint-time schema assertions, build-time bundle analysis, and post-deploy synthetic verification with rollback triggers. The Lighthouse CI configuration below uses warning thresholds for metrics still under calibration and hard errors for contracted budgets. Run at least three collections and take the median so a single slow cold-start does not turn the gate red.
{
"ci": {
"collect": {
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"throttlingMethod": "simulate",
"throttling": {
"cpuSlowdownMultiplier": 4,
"requestLatencyMs": 150,
"downloadThroughputKbps": 1638.4
}
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"resource-summary:document:size": ["error", { "maxNumericValue": 25000 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 150000 }],
"resource-summary:third-party:size": ["error", { "maxNumericValue": 80000 }],
"largest-contentful-paint": ["warn", { "maxNumericValue": 3500 }]
}
}
}
}
Wire this into a GitHub Actions job that builds, runs the assertions, and surfaces a required status check that branch protection can gate on. Route warnings to Slack for visibility, but block the merge only on error-level breaches, so a metric still under calibration informs the team without becoming a false blocker.
name: Performance Budget Gate
on:
pull_request:
branches: [main]
jobs:
budget-gate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
- name: Check bundle byte budgets
run: npx size-limit
- name: Run Lighthouse CI
run: npx lhci autorun
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-reports
path: .lighthouseci/
Require the budget-gate check in branch protection so a breach is unmergeable. The collection settings, storage backend, and assertion semantics are specified in full under Lighthouse CI Configuration & Storage. A --budget-override flag, restricted to performance leads and requiring a documented justification plus a remediation ticket, is the only sanctioned emergency bypass. Beyond the PR gate, schedule the same assertions to run against production on a fixed cadence through Continuous Performance Monitoring, because a gate that only runs on pull requests never catches the regressions that arrive through content, data volume, or third-party updates.
Observability and Regression Detection
The gate only catches what the lab can see; field telemetry closes the loop. Aggregated observability bridges CI gate results with production RUM, tracking compliance trends, surfacing regression hotspots, and correlating drops with deployment timestamps. Tag every synthetic run with its Git SHA so a field regression maps cleanly back to the offending commit, and route SLO breaches to engineering channels with automated escalation. Before you can alert on P75 drift you have to compute P75 from raw beacons, and the simplest robust approach is to sort the samples and index the percentile directly.
# p75-from-rum.sh — compute the P75 LCP from a JSON array of RUM beacons
jq -s '
map(.lcp) | sort as $s
| $s[ (($s | length) * 0.75) | floor ]
' rum-beacons.json
With P75 in hand, alerting compares the live percentile against the budget and fires only when the breach persists long enough to rule out a transient spike. The routing rules below separate a critical LCP breach that auto-rolls-back from a slow CLS drift that only pages the performance leads.
# alert-routing-rules.yaml
rules:
- name: "budget_slo_breach"
condition: "p75_lcp > budget_lcp * 1.15"
duration: "2h"
severity: "critical"
channels:
- slack: "#perf-alerts"
- pagerduty: "frontend-oncall"
actions:
- auto_rollback: true
- create_jira: "PERF-REGRESSION"
- name: "budget_drift_warning"
condition: "p75_cls > budget_cls * 1.20"
duration: "24h"
severity: "warning"
channels:
- slack: "#perf-monitoring"
actions:
- notify: "performance-leads"
Trigger recalibration when P75 field metrics drift more than 15% from the CI baseline for mid-range mobile on Fast 3G across two consecutive deployment windows. The statistical machinery that distinguishes a real regression from runner noise is the subject of Automated Regression Detection, and the dashboards that make the trend legible to a non-specialist audience live under Dashboarding & Team Adoption.
Rollout Sequencing
A budget switched from off to fully enforcing in a single pull request will produce a wall of red and a team that resents the gate. Sequence the rollout so each phase adds one enforcement surface after the previous one is stable. Observe first, gate the cheapest deterministic signal next, then layer timing metrics on once the noise floor is understood.
Phase one runs every assertion at warning level for about two weeks so the team sees the numbers on real pull requests without a single blocked merge; this is where you measure the runner's noise floor and discover which metrics are flaky before they can cost anyone a red build. Phase two promotes the byte-exact bundle checks to error level, because a 150 KB gzipped initial-script ceiling for mid-range mobile on Fast 3G at P75 is deterministic and produces almost no false positives. Phase three adds the Core Web Vitals timing assertions once you have a stable baseline and a variance-reduction strategy from Statistical Noise & Flakiness Reduction. Phase four closes the loop with field RUM and quarterly recalibration, and it is also the point at which you formalize adoption through Driving Team Performance Budget Adoption. Skipping straight to phase four is the single most common reason a budget gate gets disabled within a month.
Failure Modes and Escalation Paths
A gate the team cannot live with gets disabled. Define the triage path, the rollback triggers, and the exception process before the first red build, so a breach is a routine workflow rather than a fire drill.
- Triage checklist on a red gate — confirm the breach reproduces across
numberOfRuns: 5(rule out single-sample noise), diff the failing metric against the last green baseline, and identify whether the regression is first-party (a code change) or third-party (a vendor update). Noise is the leading cause of false reds; see Statistical Noise & Flakiness Reduction. - Rollback triggers — auto-rollback fires when production P75 LCP for mid-range mobile on Fast 3G exceeds the budget by more than 15% for two consecutive hours, or when a breach crosses a 5% conversion-impact threshold. Rollback is automatic; root-cause analysis follows, it does not block the revert.
- Exception approval — a hotfix that must bypass the gate requires a time-boxed override ticket with named engineering-director approval and a follow-up issue to restore compliance. Overrides are logged and reviewed; an exception that is never closed becomes a permanent regression.
- Quarterly recalibration — treat budget files as living configuration reviewed alongside the architectural roadmap. Infrastructure upgrades, framework migrations, and shifting user demographics all move the realistic ceiling, and a budget that is never revisited slowly diverges from the P75 reality it was meant to protect.
By embedding precise constraints into CI, correlating synthetic validation with field telemetry, and defining the escalation path up front, teams ship at velocity without trading away user experience. The budget stops being a thing the team fights and becomes a thing the team relies on — the same way a green test suite is a permission to ship, not an obstacle to it.
Frequently Asked Questions
What is the difference between a performance budget and a performance goal?
A goal is aspirational and advisory; a budget is a contracted ceiling enforced by CI that blocks a merge when breached. Goals live in slide decks and drift. Budgets live in version-controlled configuration, are asserted on every pull request, and exit non-zero on violation — see Lighthouse CI Configuration & Storage for the assertion mechanics.
Should I set budgets from lab data or field data?
Both. Anchor the realistic ceiling in field P75 from CrUX or your RUM provider, then set the lab assertion 10 to 15 percent tighter to absorb the lab-to-field gap. A common weighting is roughly 60 percent synthetic lab to 40 percent field. Specify the percentile (P75 or P90) and the environment (device class plus connection profile) every time you quote a number; the tuning method is in Percentile-Based Threshold Tuning.
Why do I need separate mobile and desktop budgets?
A single global threshold optimized for desktop hardware hides mobile regressions, because mid-range mobile on Fast 3G has far less CPU and bandwidth headroom. Maintain divergent ceilings per device class as described in Mobile vs Desktop Budget Divergence, and gate each profile independently so a desktop win cannot mask a mobile loss.
How do I roll out a budget gate without blocking every merge on day one?
Sequence it. Run every assertion at warning level for about two weeks to measure the runner's noise floor, then promote the byte-exact bundle checks to error level because they are deterministic, then add the Core Web Vitals timing assertions once you have a variance-reduction strategy, and finally close the loop with field RUM. Promoting all surfaces to error at once is the most common reason a gate gets disabled within a month.
Do single-page apps need a different budget than server-rendered pages?
Yes. A budget that only measures the initial document load passes cleanly and then silently degrades on every in-app route change, because soft navigations do not trigger a fresh document request. Add soft-navigation and route-change ceilings as covered in Single-Page App Performance Budgets, tracking INP P75 on transitions the same way you track LCP P75 on first load.