Overview
Modern teams measure perf at multiple layers:
| Layer | Runner |
|---|---|
| Backend load | k6-load-testing, jmeter-load-testing, and the Gatling / Locust references in load-testing-overview |
| Frontend | lighthouse-perf - Web Vitals via Lighthouse CI |
Each runner has its own pass/fail criterion. This gate unifies them into a single go / no-go verdict with per-metric deltas vs. main, and emits a markdown summary suitable for $GITHUB_STEP_SUMMARY or a PR comment.
This is the perf counterpart to data-quality-gate, visual-baseline-gate, and contract-compatibility-gate - same artifact shape, different domain.
When to use
If the project has only one runner, defer this gate - use the runner's native CI integration directly.
How to use
The unified metric record
Flatten every runner's output into one shape:
{
"runner": "k6",
"subject": "GET /api/orders",
"metric": "p95_latency_ms",
"value": 320,
"baseline": 280,
"delta": "+14.3%",
"budget": 500,
"status": "pass",
"severity": "blocker"
}| Field | Source |
|---|---|
runner | k6 / jmeter / gatling / locust / lighthouse. |
subject | URL path / sampler name / story ID - what was measured. |
metric | p95_latency_ms / error_rate / lcp_ms / inp_ms / cls. |
value | Current run's value. |
baseline | Last green main-branch run's value (from artifact storage / Grafana / Lighthouse CI server). |
delta | Percent change vs. baseline. |
budget | Configured threshold from the runner. |
status | pass / fail based on value vs budget. |
severity | blocker / warn. |
The gate decision rule
def gate_decision(records, *,
block_on_regression_pct=10, # block if any blocker regresses >10%
warn_on_regression_pct=3): # warn if anything regresses >3%
blockers = []
warnings = []
for r in records:
if r["status"] == "fail" and r["severity"] == "blocker":
blockers.append((r, "budget breach"))
elif r["delta_pct"] > block_on_regression_pct and r["severity"] == "blocker":
blockers.append((r, f"regression > {block_on_regression_pct}%"))
elif r["delta_pct"] > warn_on_regression_pct:
warnings.append((r, f"regression > {warn_on_regression_pct}%"))
return {
"verdict": "no-go" if blockers else "go",
"blocker_count": len(blockers),
"warning_count": len(warnings),
"blockers": blockers,
"warnings": warnings,
}Two regression triggers:
Both matter: a metric within budget but trending up still warrants a warning.
Emit the artifact
Markdown summary:
# Perf Budget Gate - verdict: NO-GO
**Blockers: 2**
| Runner | Subject | Metric | Current | Baseline | Δ | Budget | Status |
|------------|----------------------|-------------------|--------:|---------:|-------:|-------:|--------|
| k6 | POST /api/orders | p95 latency | 620ms | 280ms | +121% | 500ms | FAIL |
| lighthouse | /dashboard | LCP | 3200ms | 2100ms | +52% | 2500ms | FAIL |
**Warnings: 3**
| Runner | Subject | Metric | Current | Baseline | Δ |
|------------|----------------------|-------------------|--------:|---------:|------:|
| k6 | GET /api/orders | p95 latency | 240ms | 220ms | +9% |
| lighthouse | / | INP | 180ms | 150ms | +20% |
| locust | GET /search | p95 latency | 410ms | 380ms | +8% |Plus a JSON sibling for downstream tooling:
{
"verdict": "no-go",
"blocker_count": 2,
"warning_count": 3,
"blockers": [...],
"warnings": [...]
}A no-go verdict exits non-zero - CI halts.
Worked example
Run the gate on one build: read the k6 and Lighthouse artifacts, flatten each into records, apply the rule, print the verdict, and exit with its code.
# scripts/run_perf_gate.py
import json, csv, sys, os
from pathlib import Path
records = []
# Source: k6 summary.json
k6_path = Path("k6-summary.json")
if k6_path.exists():
s = json.loads(k6_path.read_text())
p95 = s["metrics"]["http_req_duration"]["values"]["p(95)"]
error_rate = s["metrics"]["http_req_failed"]["values"]["rate"]
records += [
{"runner": "k6", "subject": "global", "metric": "p95_latency_ms",
"value": p95, "budget": 500, "severity": "blocker",
"status": "fail" if p95 > 500 else "pass"},
{"runner": "k6", "subject": "global", "metric": "error_rate",
"value": error_rate, "budget": 0.01, "severity": "blocker",
"status": "fail" if error_rate > 0.01 else "pass"},
]
# Source: Lighthouse CI lhr-*.json
for lhr in Path(".lighthouseci/").glob("lhr-*.json"):
r = json.loads(lhr.read_text())
url = r["finalUrl"]
lcp = r["audits"]["largest-contentful-paint"]["numericValue"]
inp = r["audits"]["interaction-to-next-paint"]["numericValue"]
cls = r["audits"]["cumulative-layout-shift"]["numericValue"]
records += [
{"runner": "lighthouse", "subject": url, "metric": "lcp_ms",
"value": lcp, "budget": 2500, "severity": "blocker",
"status": "fail" if lcp > 2500 else "pass"},
{"runner": "lighthouse", "subject": url, "metric": "inp_ms",
"value": inp, "budget": 200, "severity": "blocker",
"status": "fail" if inp > 200 else "pass"},
{"runner": "lighthouse", "subject": url, "metric": "cls",
"value": cls, "budget": 0.1, "severity": "blocker",
"status": "fail" if cls > 0.1 else "pass"},
]
# Apply gate
blockers = [r for r in records if r["status"] == "fail" and r["severity"] == "blocker"]
verdict = "no-go" if blockers else "go"
print(f"# Perf Budget Gate - verdict: {verdict.upper()}")
for r in blockers:
print(f"- {r['runner']} :: {r['subject']} :: {r['metric']} = {r['value']} (budget {r['budget']})")
sys.exit(0 if verdict == "go" else 1)Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Hardcoded budgets in the gate script | Budgets evolve; updating requires code review. | Externalize to .perf-budgets.yml consumed by the gate. |
| Comparing against the previous run, not the main baseline | Drift compounds; the team approves a 5% regression every PR. | Compare against the last-known-green main commit. |
| Block on every metric | The gate becomes "the perf gate that always fails." | Block on the team's documented NFRs only; everything else is warn. |
| Skipping baseline storage | First-run-after-budget-update has nothing to compare against. | Persist baseline JSON as a build artifact uploaded on every main-branch run. |
| Asserting on Lighthouse score (categories:performance) | The score conflates LCP/INP/CLS; one bad Web Vital tanks the score uninterpretably. | Assert on individual Web Vitals; category score is supplementary. |
References
Perf budget gate - runner sources, metric budgets, and CI wiring
View source (opens in a new tab) (opens in new window)Deep reference for perf-budget-gate SKILL.md. Consult when wiring the gate into CI, mapping each runner's output artifact, or setting per-metric budgets.
Runner sources and artifacts
Each runner writes a machine-readable artifact the gate flattens into the unified metric record. Persist each as a CI build artifact (if: always()) so the gate input is reproducible and triageable.
| Source | Artifact | Produce with | Yields |
|---|---|---|---|
| k6 | summary.json | k6 run --summary-export summary.json | Per-metric values + threshold pass/fail. |
| JMeter | results.jtl + report/statistics.json | jmeter -n -t plan.jmx -l results.jtl -e -o report | Per-sampler percentiles + counts. |
| Gatling | js/stats.json under target/gatling/<sim>-<ts>/ | Gatling run (writes the HTML report bundle) | Per-request percentiles + assertion outcomes. |
| Locust | <prefix>_stats.csv | locust --csv <prefix> --headless | Per-endpoint percentiles. |
| Lighthouse CI | .lighthouseci/lhr-*.json | lhci autorun / lhci collect | Per-URL audit results including Web Vitals. |
Per-metric budgets
Back-end latency budgets come from the team's NFRs; the Core Web Vitals "good" thresholds are the canonical front-end defaults (web-vitals (opens in new window)).
| Metric | Budget | Severity (typical) |
|---|---|---|
p95_latency_ms | team NFR (e.g. 500) | blocker |
error_rate | 0.01 | blocker |
lcp_ms (Largest Contentful Paint) | 2500 | blocker |
inp_ms (Interaction to Next Paint) | 200 | blocker |
cls (Cumulative Layout Shift) | 0.1 | blocker |
| Lighthouse category score | advisory | warn |
Assert on individual Web Vitals, not the aggregate Lighthouse performance score - the score conflates LCP / INP / CLS, so one bad vital tanks it uninterpretably.
CI wiring
Run each runner, upload its artifact, then run the gate as the final step. The gate writes its markdown summary to $GITHUB_STEP_SUMMARY and exits non-zero on a no-go verdict, which fails the job (gha-summary (opens in new window)).
# .github/workflows/perf-gate.yml
- name: k6
run: k6 run --summary-export k6-summary.json load.js
- name: Lighthouse CI
run: npx lhci autorun
- name: Upload perf artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-artifacts
path: |
k6-summary.json
.lighthouseci/
- name: Perf budget gate
run: python scripts/run_perf_gate.py >> "$GITHUB_STEP_SUMMARY"Store the last green main-branch record set as a baseline artifact uploaded on every main-branch run, so per-PR deltas compare against a real baseline rather than the previous PR.