Writing a Performance Budget Policy
Most teams enforce performance numbers that live nowhere — a threshold in a lighthouserc.json, a Slack message from six months ago, an opinion. The result is that no one can answer "why is the LCP ceiling 2500 ms?" or "what happens when we need to breach it for a launch?" A written policy fixes this: it is the single document that names the thresholds, the owner, and the exception path, so the gate enforces a decision the team can read rather than folklore it has forgotten. This guide is part of Driving Team Performance Budget Adoption, and it covers exactly what goes in that document and how CI enforces it.
A good policy is short, versioned next to the code, and unambiguous about three things: what the limits are, who owns them, and how to legitimately exceed them. Anything more is bureaucracy; anything less leaves a gap the team will fill with a force-merge. Treat the policy the way you treat an API contract — a small, precise file that other people build against and that changes only through review.
Policy Section Checklist
A complete policy answers each row below. If a section is missing, that is the gap a future dispute will fall into.
| Section | What it specifies | Failure if omitted |
|---|---|---|
| Scope | Which routes and devices the budget covers | New routes ship ungated |
| Thresholds | Per-metric ceilings with percentile + device | Arguments over "what counts as slow" |
| Ownership | Accountable team handle | Orphaned budget after reorg |
| Enforcement level | warn vs error per metric | Surprise blocking checks |
| Exception workflow | How to get a time-boxed waiver | Force-merges become the escape hatch |
| Review cadence | When thresholds are recalibrated | Frozen, stale numbers |
| Sign-off | Who approved this version | No authority to point to |
Each row maps to one block in the sample file below. The diagram makes the shape of the document concrete: seven sections, each answering a single question, so a reviewer can scan the policy top to bottom and confirm nothing is missing before it merges.
Diagnostic — Find the Gaps in Your Current Process
Before writing, audit what you already enforce implicitly. Two commands surface the undocumented state.
# What thresholds does the gate actually assert today?
npx lhci assert --print-config | grep -E "maxNumericValue|minScore"
Example output:
metric-lcp: { maxNumericValue: 2500 }
resource-summary:script:size: { maxNumericValue: 200000 }
categories:performance: { minScore: 0.9 }
# Has anyone bypassed the required check recently?
gh pr list --state merged --search "status:failure" --limit 20 \
--json number,title,mergedBy
If that second command returns merged PRs that had a failing check, you have an exception process that exists only as an undocumented override — the precise gap a written policy and exception workflow close. Run both before you write a line of policy: the first tells you which numbers you are already committed to, and the second tells you how often people are quietly walking past them. A policy that ignores the real bypass rate will be ignored right back.
Implementation — A Complete Sample Policy
Save this as PERFORMANCE_BUDGET.yml at the repository root. It is complete and copy-paste ready; edit the handles and numbers to your team. Every threshold cites a percentile and device class so there is no ambiguity about what the number means.
# PERFORMANCE_BUDGET.yml
# The team's performance contract. Changing this file requires owner review.
version: 1
last_reviewed: "2026-06-20"
owner: "@frontend-platform"
review_cadence: "quarterly"
sign_off: ["@frontend-platform-lead", "@eng-manager"]
scope:
environments: ["production-mirror staging"]
devices: ["mid-range-mobile", "desktop"]
budgets:
- route: "/"
device: "mid-range-mobile" # Moto G-class, 4x CPU slowdown
connection: "fast-3g"
metrics:
lcp_ms: { target: 2500, level: error } # P75 field ceiling
inp_ms: { target: 200, level: error } # P75
cls: { target: 0.10, level: error }
script_kb: { target: 170, level: error } # gzipped
- route: "/checkout"
device: "mid-range-mobile"
connection: "fast-3g"
metrics:
lcp_ms: { target: 2800, level: warn } # new route, calibrating
inp_ms: { target: 200, level: warn }
exceptions:
how_to_request: "Open a PR editing this file; tag the owner for review."
required_fields:
- justification # why the breach is acceptable
- expiry_date # <= 30 days from merge
- tracking_issue # link to the remediation issue
default_on_new_metric: warn # never introduce a metric directly at error
review:
trigger: "quarterly, or after any device-profile recalibration"
recalibrate_against: "P75 field percentiles from the RUM dashboard"
The thresholds here are starting points derived from the methodology in Defining Web Performance Budgets — pull each ceiling from your own field data at the stated percentile rather than copying these numbers, then set the lab assertion to match so the gate and the policy agree. Notice that / ships every metric at error while the newer /checkout route runs at warn: the file records not just the limits but the maturity of each route's calibration, which is exactly the context a reviewer needs when a number changes.
Choosing Warn Versus Error for Each Metric
The single most common way a budget policy loses the team's trust is shipping a new metric straight to error. The first red build lands on an unrelated PR, the author has no idea why their change is blocked, and the check earns a reputation as noise. Avoid that by treating warn as a calibration runway. A new route or a new metric enters at warn, collects real numbers for a review cycle, and only graduates to error once the team has seen where the P75 mobile value actually sits and agreed the ceiling is achievable.
The promotion is a deliberate, reviewed edit to PERFORMANCE_BUDGET.yml, not a silent default. During the warn window the check still runs and still annotates the PR — it simply does not block — so the data accumulates without holding anyone up. When you flip the level, do it in the same PR that records the field evidence, so the commit that starts blocking merges also carries the justification for the ceiling.
Grounding Each Threshold in a Percentile
A threshold without a percentile is not a threshold — it is a wish. "LCP under 2500 ms" is meaningless until you say which LCP: the median a fast laptop sees, or the P75 a mid-range mobile on Fast 3G sees. The policy fixes one canonical reading per metric, and every number in the file must state it. The convention this template uses is P75 on a Moto G-class device under 4x CPU throttling and a Fast 3G network, because that is where the Core Web Vitals field assessment draws its own line and where most real users of a mass-market site actually sit.
Where you set the percentile is a real decision, not a formality. P75 tolerates a noisier tail and tends to be the pragmatic default for a broad audience; P90 is stricter and suited to conversion-critical routes where the slow quarter of sessions still matters commercially. Work through that trade-off in Choosing Between P75 and P90 Budget Targets, and let the broader calibration methodology in Percentile-Based Threshold Tuning drive the actual numbers you commit. Whatever you pick, write it down beside every ceiling: a reviewer who sees lcp_ms: 2500 # P75, mid-range mobile, Fast 3G can audit the number; a reviewer who sees lcp_ms: 2500 can only trust it.
| Metric | Device class | Connection | Percentile | Policy ceiling |
|---|---|---|---|---|
| LCP | Mid-range mobile | Fast 3G | P75 | 2500 ms |
| INP | Mid-range mobile | Fast 3G | P75 | 200 ms |
| CLS | Mid-range mobile | Fast 3G | P75 | 0.10 |
| LCP | Desktop | Cable | P90 | 1800 ms |
| Initial script | Mid-range mobile | Fast 3G | P75 | 170 KB |
The Exception Workflow
The exception path is what separates a policy people respect from one they route around. If breaching a ceiling for a genuine launch requires nothing more than a force-merge, the force-merge becomes the process. The policy's job is to make the legitimate path easier than the bypass: open a PR editing PERFORMANCE_BUDGET.yml, record three fields — a justification, an expiry date no more than 30 days out, and a link to the remediation issue — and tag the owner. The waiver is now a reviewed, attributable, time-boxed commit, and CI enforces the expiry so it cannot quietly become permanent.
CI Gating and Codeowner Enforcement
The policy enforces itself through two mechanisms. A required check asserts the thresholds; CODEOWNERS makes any edit to the policy file visible to the owner.
# CODEOWNERS
/PERFORMANCE_BUDGET.yml @frontend-platform
/lighthouserc.json @frontend-platform
# .github/workflows/budget-policy.yml
name: Budget Policy
on:
pull_request:
branches: [main]
jobs:
enforce:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- run: npm ci && npm run build
- name: Assert budget
run: npx lhci autorun
env:
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
- name: Fail expired exceptions
run: |
python3 - <<'PY'
import yaml, datetime, sys
doc = yaml.safe_load(open("PERFORMANCE_BUDGET.yml"))
today = datetime.date.today()
for b in doc.get("budgets", []):
for name, m in b["metrics"].items():
exp = m.get("expiry_date")
if exp and datetime.date.fromisoformat(str(exp)) < today:
sys.exit(f"Expired exception on {b['route']} {name}")
print("No expired exceptions.")
PY
Mark enforce a required status check. Now loosening a number means editing PERFORMANCE_BUDGET.yml, which routes a review to @frontend-platform, and any exception that outlives its expiry_date fails CI automatically — so waivers are genuinely time-boxed instead of permanent. The two mechanisms cover two different failure modes: the required check stops a regression from merging, and CODEOWNERS stops a threshold change from merging without the owner seeing it. You need both, because a team that can silently edit the ceiling can pass any check.
Verification — Adoption Signals
After the policy ships, confirm it is load-bearing rather than decorative:
- The required check appears on every PR and has blocked at least one real regression.
- A change to
PERFORMANCE_BUDGET.ymltriggers a review request to the owner — open a test PR and confirm. gh pr list --state merged --search "status:failure"returns nothing new, meaning the exception PR is the only path past a red gate.- The
last_revieweddate is within one review cadence; a stale date is the earliest sign the policy is drifting back into folklore.
A passing state looks like every merged PR carrying a green budget check, every threshold change carrying the owner's approval, and zero expired exceptions in CI. Once those signals hold, the natural next move is to make compliance visible beyond the PR — roll the same numbers up into Performance Budget Reporting and Scorecards so the leadership that sponsored the policy can see it working without reading CI logs.
Frequently Asked Questions
Where should the policy file live?
At the repository root as PERFORMANCE_BUDGET.yml, versioned next to the gate config it governs and guarded by CODEOWNERS. Keeping it in the repo means a threshold change is a reviewable, attributable commit rather than an edit to a wiki nobody watches.
How detailed should an exception entry be?
Three fields are enough: a justification, an expiry date no more than 30 days out, and a link to the remediation issue. A CI step that fails any exception past its expiry keeps waivers honest. Derive the underlying thresholds from Defining Web Performance Budgets so the baseline the exception relaxes is itself grounded in field data.
Should a new metric start at warn or error?
Always warn. Introducing a metric directly at error means the first red build lands on an unrelated PR whose author has no context, and the check earns a reputation as noise. Let a new metric or route sit at warn for one review cycle, calibrate the ceiling against the P75 mobile value you actually observe, then promote it to error in the same PR that records the evidence.
Which percentile and device should each threshold cite?
Every ceiling must name both. The default in this template is P75 on a Moto G-class mid-range mobile under 4x CPU throttling and Fast 3G, matching the Core Web Vitals field assessment. Use P90 for conversion-critical routes where the slow quarter of sessions still matters; the trade-off is worked through in Choosing Between P75 and P90 Budget Targets.
How often should the policy be reviewed?
Quarterly is a sensible default, plus an ad-hoc review after any device-profile recalibration. The last_reviewed field makes drift visible: if that date is older than one cadence, the numbers are probably stale and the policy is sliding back toward folklore. Recalibrate against the current P75 field percentiles from your RUM dashboard, not against last year's lab run.