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.
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 |
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.
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.
CI Gating & 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.
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.
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.