Skip to content

Performance Engineering & Distributed Load Testing with k6

Skill metadata

Name performance-load-testing · Level mid · Tags performance load-testing k6 locust benchmarking sre

"Performance engineering with k6 and Locust: load, stress, spike and soak profiles, virtual users (VUs), latency percentiles (p95/p99), threshold-gated tests in CI, and bottleneck profiling. Use when writing a k6 or Locust script, deciding whether an API can handle a peak traffic event, capacity planning before launch, or investigating why average latency looks fine while users report slowness."

Source: skills/devops-core/performance-load-testing/SKILL.md

When to Use This Skill

Triggers — load this skill when:

  • A service needs a load, stress, spike, or soak profile authored and run
  • You must set or verify latency/error thresholds as a release gate
  • Capacity planning or a suspected regression needs measured evidence

Route elsewhere when:

  • Turning measured latency into an SLO and error budget -> sli-slo-error-budget-design
  • Autoscaling and failover architecture -> scalability-high-availability-patterns

1. Declarative k6 Load & Stress Test Script (load_test.js)

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 50 },  // Ramp up to 50 users
    { duration: '5m', target: 200 }, // Steady high load
    { duration: '2m', target: 500 }, // Stress spike
    { duration: '1m', target: 0 },   // Ramp down
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],         // Error rate must be < 1%
    http_req_duration: ['p(95)<250', 'p(99)<500'], // 95% < 250ms, 99% < 500ms
  },
};

export default function () {
  const params = {
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer test-token-12345',
    },
  };

  const res = http.get('https://api.staging.internal/v1/catalog', params);
  check(res, {
    'status is 200': (r) => r.status === 200,
    'body has payload': (r) => r.body.length > 0,
  });
  sleep(1);
}

2. Best Practices & Anti-Patterns

  • Do: Run baseline load tests in staging before every major architectural release or migration.
  • Do: Pay attention to the p99 and p99.9 latency tail rather than average latency, which hides micro-bursts and lock contention.
  • Don't: Never run uncoordinated load tests against production shared databases without circuit breakers and rollback plans.

3. Reading the Percentiles

Statistic What it hides Use it for
mean Everything that matters; a bimodal distribution has no meaningful average Capacity arithmetic only
p50 The tail entirely Sanity baseline
p95 The worst 1 in 20 requests The usual SLO target and release gate
p99 Lock contention, GC pauses, cold caches Tail-latency work, the metric users notice
p99.9 Nothing — but is noisy at low request volume Large-scale systems with enough samples

A p95 threshold is what you gate a build on; a p99 regression is what you investigate. Report both with the request count, since a percentile computed over 200 requests is not evidence. Percentiles also do not average: never take the mean of per-instance p95 values — aggregate the raw histogram (histogram_quantile over summed buckets) instead.


4. Virtual Users, Arrival Rate, and Locust

A virtual user (VU) is one concurrent synthetic client executing the script in a loop. VUs are a concurrency model, not a request rate: 100 VUs against a 50 ms endpoint generate far more load than 100 VUs against a 2 s endpoint. When the requirement is expressed in requests per second, drive arrival rate directly instead:

export const options = {
  scenarios: {
    steady_rps: {
      executor: 'constant-arrival-rate',
      rate: 500, timeUnit: '1s',        // 500 rps regardless of latency
      duration: '10m',
      preAllocatedVUs: 200, maxVUs: 1000,
    },
  },
};

Locust is the Python-native alternative — worth choosing when the load logic needs real application libraries or a distributed master/worker fleet:

from locust import HttpUser, task, between

class CatalogUser(HttpUser):
    wait_time = between(0.5, 2)

    @task(3)
    def browse(self):
        with self.client.get("/v1/catalog", name="/v1/catalog", catch_response=True) as r:
            if r.elapsed.total_seconds() > 0.5:
                r.failure("slower than 500ms SLO")

Both report percentiles; only k6 gates natively on thresholds, so Locust runs usually need an explicit exit-code check in CI.