UXDL Docs

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

TypeQuestion it answersExample
LoadDoes it meet SLOs at expected traffic?500 concurrent users, p95 < 400 ms
StressWhere does it break?Ramp until error rate climbs
SpikeCan it absorb sudden surges?50 → 2,000 users in 30 s
SoakDoes it leak/degrade over time?Steady load for 2–4 hours
RealtimeDo WebSocket flows scale?10k concurrent Socket.io clients

Tooling

ConcernToolWhy
API / HTTP loadk6Scriptable in JS, great CI output, thresholds as code
Scenario / multi-stepArtilleryYAML scenarios, Socket.io engine built in
Realtime / WebSocketk6 (xk6-websockets) or Artillery Socket.ioSimulate many persistent connections
Python servicesLocustPythonic load scripts (FastAPI teams)
Frontend perfLighthouse CI + web-vitalsCore 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

RunWhereWhenGate
Quick baselineLocal (k6 run)While developing a hot pathInformational
Nightly loadGitHub Actions (cron)Scheduled, vs BetaAlerts on regression
Pre-releaseManual / workflow_dispatchBefore promoting to prodBlocks release if thresholds fail
Frontend budgetsGitHub ActionsEvery PR (Lighthouse CI)Warns / blocks on budget breach

API load test (k6)

bash
brew install k6        # macOS — see k6 docs for other platforms
k6 run load/api.js
javascript
// 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.

yaml
# 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: 10
bash
npx artillery run load/socket.yml

Things 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)

bash
pnpm add -D @lhci/cli
npx lhci autorun
json
// 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:

MetricTarget
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

yaml
# .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.

Official documentation