Load Testing with k6 skill for Claude Code
A verdict and a bottleneck, not just a pile of numbers. Claude starts every load test from a written question and SLO thresholds, then writes k6 scenarios with open-model arrival rates, tagged endpoints, and realistic data. After the run it reads tail percentiles rather than averages, finds the knee where latency climbs, checks for dropped iterations, and names the bottleneck in a short capacity report with a pass or fail verdict.
- name
load-testing - size190 lines · ≈1,950 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs, runs, and interprets k6 load tests with explicit goals, open-model arrival-rate scenarios, ramping stages, pass/fail thresholds on p95/p99 latency and error rate, tagged endpoints, and a capacity report with the breaking point and bottleneck. Covers smoke, load, stress, spike, soak, and breakpoint test types, test data, and avoiding coordinated omission. Use when asked to load test or stress test an API, find how many requests per second a service handles, validate an SLO before launch, or read k6 output. Try asking
- Write a k6 test to check our API handles 300 requests per second at p95 under 400ms
- Here's our k6 output. Did we pass, and what is the bottleneck?
- Find the breaking point of the search endpoint with a stepped stress test
Use it when
- Validating an SLO or capacity target before a launch
- Finding the breaking point of a service
- Interpreting k6 results or comparing against a previous run
- Adding a performance gate to CI
Skip it when
- You need to profile why one request is slow: use node-profiling or python-profiling
- You are measuring frontend page speed: use core-web-vitals
How to check the result
Run scripts/k6_summary.py on the exported summary: all thresholds pass, dropped iterations are zero, and p95 has not regressed beyond the agreed limit against the baseline.
The common mistake it prevents
Using a fixed pool of looping VUs for a requests-per-second goal, which quietly sends less traffic as the server slows and reports better latency than users would see.
A load test answers a written question (“can checkout sustain 300 req/s at p95 < 400 ms with < 0.5% errors?”) with a pass/fail verdict and a named bottleneck. Running load without a target, thresholds, and server-side metrics produces numbers, not answers.
Ground rules
- Never load test production or third-party APIs without explicit written permission from the owner, a time window, and a stop plan. Default to a production-like staging stack.
- Write the goal and thresholds before the script.
- Use arrival-rate executors (open model) for request-rate goals. VU-based loops slow down when the server slows down and hide latency (coordinated omission).
- Watch server metrics (CPU, memory, DB connections, queue depth, GC) during the run; client numbers alone cannot locate the bottleneck.
- Run the load generator close to the target network-wise and confirm the generator
itself is not saturated (CPU < ~80%, no
dropped_iterations).
Workflow
- [ ] 1. Define goal: traffic model, target rate, SLO thresholds, test type
- [ ] 2. Confirm environment, permission, data, and monitoring
- [ ] 3. Write the script with scenarios, tags, checks, thresholds
- [ ] 4. Smoke test (1–2 VUs, 1 min) to validate the script
- [ ] 5. Run the real test; watch server dashboards live
- [ ] 6. Analyze: thresholds, percentiles over time, saturation point
- [ ] 7. Write the capacity report
1. Choose the test type
| Question | Type | Shape |
|---|---|---|
| Does the script work? | Smoke | 1–2 VUs, 1 min |
| Does it meet SLO at expected peak? | Load | Ramp to peak, hold 10–30 min |
| Where does it break and how? | Stress / breakpoint | Increase rate in steps until thresholds fail; abortOnFail |
| Can it absorb a sudden burst? | Spike | Jump to 5–10x in seconds, hold briefly, drop |
| Does it degrade over hours (leaks, pool exhaustion)? | Soak | Expected load for 2–8 h |
Derive the target rate from data: peak requests/s from access logs or APM, multiplied by expected growth and a headroom factor (commonly 1.5–2x). Model the request mix per endpoint from real traffic proportions.
3. Script
// load/checkout.js
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE = __ENV.BASE_URL || 'https://staging.example.com';
export const options = {
scenarios: {
browse: {
executor: 'ramping-arrival-rate',
exec: 'browse',
startRate: 20, timeUnit: '1s',
preAllocatedVUs: 100, maxVUs: 500,
stages: [
{ duration: '3m', target: 200 }, // ramp
{ duration: '15m', target: 200 }, // hold at peak
{ duration: '2m', target: 0 },
],
},
checkout: {
executor: 'constant-arrival-rate',
exec: 'checkout',
rate: 20, timeUnit: '1s', duration: '20m',
preAllocatedVUs: 50, maxVUs: 200,
},
},
thresholds: {
http_req_failed: ['rate<0.005'],
'http_req_duration{name:product}': ['p(95)<300', 'p(99)<800'],
'http_req_duration{name:checkout}': [{ threshold: 'p(95)<500', abortOnFail: true, delayAbortEval: '2m' }],
checks: ['rate>0.99'],
},
summaryTrendStats: ['avg', 'med', 'p(90)', 'p(95)', 'p(99)', 'max'],
};
export function setup() {
const res = http.post(`${BASE}/api/test/tokens`, JSON.stringify({ count: 500 }),
{ headers: { 'Content-Type': 'application/json' } });
return { tokens: res.json('tokens') };
}
export function browse() {
const id = Math.floor(Math.random() * 10000) + 1;
const res = http.get(`${BASE}/api/products/${id}`, { tags: { name: 'product' } });
check(res, { 'status 200': (r) => r.status === 200 });
sleep(Math.random() * 2); // think time; does not reduce arrival rate in open model
}
export function checkout(data) {
const token = data.tokens[__ITER % data.tokens.length];
const res = http.post(`${BASE}/api/checkout`, JSON.stringify({ sku: 'A-1', qty: 1 }), {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
tags: { name: 'checkout' },
});
check(res, { 'order created': (r) => r.status === 201 });
}
export function handleSummary(data) {
return { 'summary.json': JSON.stringify(data, null, 2) };
}
Script rules:
- Tag every request with a stable
name; URLs with IDs otherwise explode into thousands of metric series. - Thresholds per critical endpoint, not just global. A fast health endpoint can hide a slow checkout in the aggregate.
- Use realistic data: varied IDs, auth tokens for many users, cache-busting where real traffic would miss caches. Hitting one ID measures your cache.
- Keep
checkfor correctness (status, body), thresholds for SLOs. - Default
handleSummaryoutput to stdout is replaced when you define it; addstdout: textSummary(...)from the k6 jslib if you want both.
4–5. Run
k6 run load/checkout.js -e BASE_URL=https://staging.example.com
k6 run --vus 1 --iterations 5 load/checkout.js # quick script check
K6_WEB_DASHBOARD=true k6 run load/checkout.js # live dashboard on localhost:5665
k6 run --out json=raw.json load/checkout.js # per-request data points (large)
k6 exits non-zero when any threshold fails, which makes it a CI gate. Stop the test if error rates spike in a way that risks shared infrastructure.
6. Analyze
python3 scripts/k6_summary.py summary.json
python3 scripts/k6_summary.py summary.json --baseline previous/summary.json --max-regression 10
The script prints throughput, error rate, per-endpoint percentiles, threshold verdicts, dropped iterations, and p95 regressions against a baseline; it exits 1 on any failure.
Interpretation rules:
- p95/p99, never averages. Averages hide the tail users feel.
- Find the knee. In a stepped test, the capacity limit is the last step where latency is flat and errors are under threshold. Past it, latency climbs sharply while throughput plateaus.
- Little’s law sanity check: concurrent requests in flight = throughput x latency. 200 req/s at 0.25 s means ~50 in flight; if the DB pool is 20, expect queueing.
dropped_iterations> 0 means the generator could not start iterations on schedule: results understate load. RaisemaxVUsor add generators.- Errors first. A fast p95 with 5% 503s is a failure; fast errors pull latency down.
- Correlate the knee with server metrics to name the bottleneck: CPU-bound app, DB connection pool exhaustion, lock contention, downstream rate limits, GC pauses.
Gotchas
- Testing from a laptop over Wi-Fi measures the Wi-Fi.
- Autoscaling targets need a ramp longer than scale-up time; otherwise you test cold capacity. Note min/max replicas in the report.
- Staging with a 10x smaller DB or warm caches yields optimistic numbers. Record the environment differences.
- Rate limiting or WAF rules on the target will return 429/403 and look like capacity limits. Allowlist the generator IPs with the owner.
sleepin arrival-rate scenarios increases needed VUs but not request rate; in VU scenarios it directly lowers request rate.
Capacity report
# Load test: checkout API — 2026-03-14
**Question:** Sustain 200 req/s browse + 20 req/s checkout at p95 < 300/500 ms, errors < 0.5%?
**Verdict:** PASS at target. Breaking point ~340 req/s total.
**Environment:** staging, 4 x 2 vCPU app pods (HPA off), db.r6g.large, same region as generator
| Endpoint | Rate | p95 | p99 | Errors |
|---|---|---|---|---|
| product | 200/s | 182 ms | 410 ms | 0.02% |
| checkout | 20/s | 390 ms | 720 ms | 0.10% |
**Bottleneck:** DB connection pool (20/pod) saturated at ~340 req/s; app CPU 55%.
**Recommendations:** raise pool to 40 with PgBouncer; re-test. Add index on orders(user_id).
**Raw:** summary.json, dashboard export, Grafana snapshot link Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/k6_summary.py
#!/usr/bin/env python3
"""Summarize a k6 handleSummary JSON export and optionally compare to a baseline.
Usage:
python3 scripts/k6_summary.py summary.json
python3 scripts/k6_summary.py summary.json --baseline baseline.json --max-regression 10
The input is the JSON written by a handleSummary() that returns
{ 'summary.json': JSON.stringify(data) }.
Exit codes: 0 = all thresholds passed and no regression beyond the limit,
1 = a threshold failed or a tracked percentile regressed,
2 = bad input.
"""
import argparse
import json
import sys
TREND_KEYS = ["avg", "med", "p(90)", "p(95)", "p(99)", "max"]
def load(path):
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
sys.exit(2)
metrics = data.get("metrics")
if not isinstance(metrics, dict):
print(f"error: {path} has no 'metrics' object; export it with handleSummary()", file=sys.stderr)
sys.exit(2)
return metrics
def values(metric):
# handleSummary format nests numbers under "values"; tolerate flat objects too.
if isinstance(metric, dict) and isinstance(metric.get("values"), dict):
return metric["values"]
return metric if isinstance(metric, dict) else {}
def fmt(v, unit="ms"):
if v is None:
return "-"
if unit == "ms":
return f"{v:,.1f} ms" if v < 10_000 else f"{v / 1000:,.2f} s"
if unit == "%":
return f"{v * 100:.2f}%"
return f"{v:,.2f}"
def trend_rows(metrics):
rows = []
for name in sorted(metrics):
m = metrics[name]
if m.get("type") == "trend" or (name.startswith("http_req_duration") and "p(95)" in values(m)):
rows.append((name, values(m)))
return rows
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("summary")
ap.add_argument("--baseline", help="earlier summary.json to compare against")
ap.add_argument("--max-regression", type=float, default=10.0,
help="allowed p95 increase in percent before failing (default 10)")
args = ap.parse_args()
metrics = load(args.summary)
failed = False
v = lambda n: values(metrics.get(n, {}))
reqs, err, checks = v("http_reqs"), v("http_req_failed"), v("checks")
dropped, vus = v("dropped_iterations"), v("vus_max")
print("== Throughput and errors")
print(f" requests {reqs.get('count', '-')} ({fmt(reqs.get('rate'), 'n')}/s)")
print(f" failed rate {fmt(err.get('rate'), '%')}")
print(f" checks passed {fmt(checks.get('rate'), '%')}")
print(f" max VUs {vus.get('max', vus.get('value', '-'))}")
if dropped.get("count"):
print(f" DROPPED ITERATIONS {dropped['count']}: the load generator could not sustain the "
"target arrival rate; raise preAllocatedVUs/maxVUs or use more generators")
print("\n== Latency (trend metrics)")
header = f" {'metric':48}" + "".join(f"{k:>12}" for k in TREND_KEYS)
print(header)
for name, vals in trend_rows(metrics):
print(f" {name[:48]:48}" + "".join(f"{fmt(vals.get(k)):>12}" for k in TREND_KEYS))
print("\n== Thresholds")
any_thr = False
for name in sorted(metrics):
thr = metrics[name].get("thresholds") or {}
for expr, res in thr.items():
any_thr = True
ok = res.get("ok") if isinstance(res, dict) else None
status = "PASS" if ok is True else "FAIL" if ok is False else "UNKNOWN"
if ok is False:
failed = True
print(f" {status:8} {name}: {expr}")
if not any_thr:
print(" none defined: add thresholds so the run has a pass/fail verdict")
if args.baseline:
base = load(args.baseline)
print(f"\n== Regression vs baseline (limit +{args.max_regression:.0f}% on p95)")
for name, vals in trend_rows(metrics):
b = values(base.get(name, {})).get("p(95)")
c = vals.get("p(95)")
if b is None or c is None or b == 0:
continue
delta = (c - b) / b * 100
flag = "REGRESSION" if delta > args.max_regression else "ok"
if flag == "REGRESSION":
failed = True
print(f" {flag:10} {name}: {fmt(b)} -> {fmt(c)} ({delta:+.1f}%)")
be = values(base.get("http_req_failed", {})).get("rate")
ce = err.get("rate")
if be is not None and ce is not None:
print(f" error rate: {fmt(be, '%')} -> {fmt(ce, '%')}")
print("\nRESULT:", "FAIL" if failed else "PASS")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.