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.

Policy document anatomy A stack of seven labelled sections showing what each part of a budget policy specifies. Seven sections every budget policy must answer Scope Which routes and device classes the budget covers Thresholds Per-metric ceilings, each with a percentile and device Ownership The accountable team handle that reviews changes Enforcement warn versus error, set independently per metric Exceptions The time-boxed waiver path with an expiry date Review cadence When the numbers get recalibrated against field data Sign-off Who approved this version, and on what date
Each section answers exactly one question, so a reviewer can confirm completeness in a single top-to-bottom scan.

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.

Promoting a new route from warn to error A horizontal timeline split into a gold warn segment and a crimson error segment with week markers. Promoting a new route from warn to error Calibrate against P75 field data Blocks merges on regression warn error Week 0 — route ships Week 4 — promote ongoing enforcement
A new route spends its first review cycle at warn so the ceiling is calibrated against real P75 mobile data before it starts blocking merges.

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.

Exception workflow A breach request becomes a reviewed waiver with an expiry date that CI checks on every run. How an exception moves from request to auto-expiry 1. Breach needed for a launch 2. PR edits the policy file 3. Owner review via CODEOWNERS 4. Waiver recorded: justification, expiry within 30 days, tracking issue Past expiry? No: gate passes Yes: CI fails
The waiver is a reviewed commit with an expiry the gate checks on every run, so it self-destructs instead of lingering as a permanent exemption.

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.

CI enforcement paths A pull request triggers both a required threshold check and a CODEOWNERS review of the policy file. Pull request opened Required check runs npx lhci autorun asserts each ceiling Policy file edited? CODEOWNERS matches the path Any error-level metric over its ceiling means the check fails Review request routed to the accountable owner handle
One pull request triggers both guards: the required check blocks regressions, and CODEOWNERS blocks any unreviewed edit to the ceilings themselves.

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.yml triggers 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_reviewed date 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.