Performance & Load Testing
k6, Artillery, and Lighthouse — load, stress, soak, realtime, and frontend perf.
Performance testing answers a different question than functional tests: not "does it work?" but "does it stay fast and stable under load?". This page covers backend load/stress/soak, realtime (WebSocket) load, and frontend performance — what tool to use, where it runs, and the thresholds that gate a release.
Test types
| Type | Question it answers | Example |
|---|---|---|
| Load | Does it meet SLOs at expected traffic? | 500 concurrent users, p95 < 400 ms |
| Stress | Where does it break? | Ramp until error rate climbs |
| Spike | Can it absorb sudden surges? | 50 → 2,000 users in 30 s |
| Soak | Does it leak/degrade over time? | Steady load for 2–4 hours |
| Realtime | Do WebSocket flows scale? | 10k concurrent Socket.io clients |
Tooling
| Concern | Tool | Why |
|---|---|---|
| API / HTTP load | k6 | Scriptable in JS, great CI output, thresholds as code |
| Scenario / multi-step | Artillery | YAML scenarios, Socket.io engine built in |
| Realtime / WebSocket | k6 (xk6-websockets) or Artillery Socket.io | Simulate many persistent connections |
| Python services | Locust | Pythonic load scripts (FastAPI teams) |
| Frontend perf | Lighthouse CI + web-vitals | Core Web Vitals budgets per route |
We standardize on k6 for backend/API and realtime load, and Lighthouse CI for frontend performance budgets.
Where it runs
| Run | Where | When | Gate |
|---|---|---|---|
| Quick baseline | Local (k6 run) | While developing a hot path | Informational |
| Nightly load | GitHub Actions (cron) | Scheduled, vs Beta | Alerts on regression |
| Pre-release | Manual / workflow_dispatch | Before promoting to prod | Blocks release if thresholds fail |
| Frontend budgets | GitHub Actions | Every PR (Lighthouse CI) | Warns / blocks on budget breach |
API load test (k6)
brew install k6 # macOS — see k6 docs for other platforms
k6 run load/api.js// load/api.js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "1m", target: 100 }, // ramp up
{ duration: "3m", target: 500 }, // sustain expected peak
{ duration: "1m", target: 0 }, // ramp down
],
thresholds: {
http_req_failed: ["rate<0.01"], // <1% errors
http_req_duration: ["p(95)<400"], // p95 under 400ms
},
};
const BASE = __ENV.BASE_URL; // e.g. https://beta.company.com
const TOKEN = __ENV.TOKEN;
export default function () {
const res = http.get(`${BASE}/v1/projects`, {
headers: { Authorization: `Bearer ${TOKEN}` },
});
check(res, {
"status is 200": (r) => r.status === 200,
"fast enough": (r) => r.timings.duration < 800,
});
sleep(1);
}k6 exits non-zero when a threshold fails, so CI fails automatically — no extra assertion wiring needed.
Realtime / WebSocket load (Socket.io)
Realtime systems break differently than REST — the cost is in concurrent persistent connections, presence fan-out, and reconnection storms. Test those explicitly.
# load/socket.yml — Artillery with the Socket.io engine
config:
target: "https://beta.company.com"
phases:
- duration: 120
arrivalRate: 50 # 50 new clients/sec → builds to thousands concurrent
engines:
socketio-v3: {}
scenarios:
- engine: socketio-v3
flow:
- emit:
channel: "join"
data: { room: "load-test" }
- think: 5
- emit:
channel: "message"
data: { room: "load-test", body: "hello" }
- think: 10npx artillery run load/socket.ymlThings to assert for realtime: connection success rate, message round-trip latency, server memory/CPU during fan-out, and behavior when many clients reconnect at once.
Frontend performance (Lighthouse CI)
pnpm add -D @lhci/cli
npx lhci autorun// lighthouserc.json
{
"ci": {
"collect": { "numberOfRuns": 3, "url": ["https://beta.company.com/"] },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
}
}
}Track Core Web Vitals — LCP, CLS, INP — as budgets per key route so regressions fail the PR.
SLO thresholds (defaults)
Tune per service, but start here:
| Metric | Target |
|---|---|
| HTTP error rate | < 1% |
| API p95 latency | < 400 ms |
| API p99 latency | < 800 ms |
| WebSocket connect success | > 99.5% |
| Realtime message round-trip p95 | < 300 ms |
| Lighthouse performance | ≥ 0.85 |
| LCP | < 2.5 s |
GitHub Actions — nightly load
# .github/workflows/load-nightly.yml
name: Load — Beta (nightly)
on:
schedule:
- cron: "0 2 * * *" # 02:00 UTC daily
workflow_dispatch: {} # manual pre-release runs
jobs:
k6:
runs-on: ubuntu-latest
timeout-minutes: 30
environment: beta
steps:
- uses: actions/checkout@v4
- name: Run k6 load test
uses: grafana/k6-action@v0.3.1
with:
filename: load/api.js
env:
BASE_URL: https://beta.company.com
TOKEN: ${{ secrets.LOAD_TEST_TOKEN }}A failed threshold fails the job and alerts the owning team.
Best practices
- Version load scripts with the service — they live in the repo (
load/), reviewed like code. - Use representative data — empty databases give meaningless numbers; seed Beta realistically.
- Warm up before measuring (ignore cold-start in steady-state assertions).
- Isolate runs — one load test at a time per environment; announce in
#engineering. - Profile the failure — when a threshold breaks, capture traces/metrics (Observability) to find the bottleneck.
Related guides
- Testing Overview — full strategy and where this fits
- Environments — why load runs target Beta
- Observability — metrics and traces to read during a run
- Production Rollback — if a perf regression reaches Prod
Official documentation
- k6 · k6 thresholds
- Artillery · Artillery Socket.io
- Lighthouse CI
- Locust — Python load testing
- Core Web Vitals