Load testing is not optional for production systems. Without it, you discover your capacity limits when real users hit them. This guide covers setting up k6 and Locust, designing test scenarios for different failure modes, establishing baseline metrics, running stress, soak, and spike tests safely against production, and building automated performance reports.
According to PagerDuty's 2026 incident analysis, 34% of production outages are capacity-related and preventable with load testing. Teams that run weekly load tests experience 67% fewer performance-related incidents. The average cost of a performance-related outage for a SaaS company is $5,600 per minute.
---
Test Types and When to Use Each
| Test Type | Purpose | Duration | Load Pattern | When to Run |
| Smoke | Verify test scripts work | 1-2 min | 1-5 users | Every CI/CD run |
| Load | Validate expected traffic | 10-15 min | Normal traffic | Weekly |
| Stress | Find breaking point | 15-30 min | Ramp beyond capacity | Before launches |
| Soak | Find memory leaks | 2-8 hours | Sustained normal load | Monthly |
| Spike | Test sudden traffic bursts | 5-10 min | Sudden 10x spike | Before marketing campaigns |
| Breakpoint | Find absolute maximum | 15-30 min | Continuous ramp | Quarterly |
---
Setting Up k6
k6 is a developer-centric load testing tool that uses JavaScript for test scripts.
<h1>Install k6</h1>
<h1>macOS</h1>
brew install k6
<h1>Linux (Debian/Ubuntu)</h1>
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6
k6 Load Test Script
// tests/load/api-load-test.js
import http from "k6/http";
import { check, group, sleep } from "k6";
import { Rate, Trend } from "k6/metrics";
// Custom metrics
const errorRate = new Rate("errors");
const apiDuration = new Trend("api_duration");
export const options = {
scenarios: {
// Smoke test
smoke: {
executor: "constant-vus",
vus: 3,
duration: "1m",
tags: { test_type: "smoke" },
},
// Load test
load: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "3m", target: 50 },
{ duration: "10m", target: 50 },
{ duration: "3m", target: 0 },
],
startTime: "2m",
tags: { test_type: "load" },
},
// Stress test
stress: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "2m", target: 50 },
{ duration: "5m", target: 100 },
{ duration: "5m", target: 200 },
{ duration: "5m", target: 300 },
{ duration: "3m", target: 0 },
],
startTime: "20m",
tags: { test_type: "stress" },
},
},
thresholds: {
http_req_duration: ["p(95)<500", "p(99)<1000"],
errors: ["rate<0.05"],
http_req_failed: ["rate<0.01"],
},
};
const BASE_URL = __ENV.BASE_URL || "https://app.arxentra.cloud";
export default function () {
group("API Health", () => {
const res = http.get(`${BASE_URL}/api/health`);
check(res, {
"health status 200": (r) => r.status === 200,
"health response < 100ms": (r) => r.timings.duration < 100,
});
errorRate.add(res.status !== 200);
apiDuration.add(res.timings.duration);
});
group("Service List", () => {
const res = http.get(`${BASE_URL}/api/services`, {
headers: { Authorization: `Bearer ${__ENV.API_TOKEN}` },
});
check(res, {
"services status 200": (r) => r.status === 200,
"services response < 500ms": (r) => r.timings.duration < 500,
});
errorRate.add(res.status !== 200);
});
sleep(Math.random() * 3 + 1);
}
---
Setting Up Locust
Locust is Python-based and better for complex user behavior simulation.
<h1>tests/load/locustfile.py</h1>
from locust import HttpUser, task, between
class WebAppUser(HttpUser):
wait_time = between(1, 5)
host = "https://app.arxentra.cloud"
def on_start(self):
# Login and get session
response = self.client.post("/api/auth/login", json={
"username": "loadtest-user",
"password": "test-password"
})
self.token = response.json().get("token", "")
@task(5)
def view_dashboard(self):
self.client.get("/api/services",
headers={"Authorization": f"Bearer {self.token}"})
@task(2)
def view_service_detail(self):
self.client.get("/api/services/1",
headers={"Authorization": f"Bearer {self.token}"})
@task(1)
def check_health(self):
self.client.get("/api/health")
<h1>Run Locust with web UI</h1>
locust -f tests/load/locustfile.py --users 100 --spawn-rate 10
<h1>Run headless for CI/CD</h1>
locust -f tests/load/locustfile.py --headless \
--users 100 --spawn-rate 10 --run-time 10m \
--csv=results/load-test
---
Baseline Metrics to Track
| Metric | Target | Critical Threshold | Action if Exceeded |
| p50 response time | < 100ms | > 200ms | Investigate slow queries |
| p95 response time | < 500ms | > 1000ms | Scale or optimize |
| p99 response time | < 1000ms | > 3000ms | Emergency investigation |
| Error rate | < 0.1% | > 1% | Stop test, investigate |
| Throughput (RPS) | Baseline +/- 10% | > 20% drop | Check resource saturation |
| CPU utilization | < 70% | > 85% | Scale horizontally |
| Memory utilization | < 80% | > 90% | Check for leaks |
| DB connection count | < pool max | At pool max | Increase pool or add PgBouncer |
---
Soak Testing for Memory Leaks
Soak tests run at normal load for extended periods to detect memory leaks, connection leaks, and gradual performance degradation.
// k6 soak test configuration
export const options = {
stages: [
{ duration: "5m", target: 50 }, // ramp up
{ duration: "4h", target: 50 }, // sustained load
{ duration: "5m", target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ["p(95)<500"],
http_req_failed: ["rate<0.01"],
},
};
// During the soak test, monitor:
// - RSS memory of application processes (should be stable)
// - PostgreSQL connection count (should not grow)
// - Response time trend (should not increase over time)
// - Disk usage (log files growing unbounded?)
A healthy application shows flat memory usage during a soak test. If memory grows linearly, you have a leak. If response times gradually increase, you have resource exhaustion (connections, file handles, or thread pool).
---
Production-Safe Load Testing
Testing against production requires safeguards to prevent customer impact.
For managed load testing environments, Arxentra provides isolated testing infrastructure. See Cloud Computing for testing environment options.
---
Automated Reporting
<h1>Generate HTML report from k6 results</h1>
k6 run --out json=results.json tests/load/api-load-test.js
<h1>Use k6-reporter for HTML output</h1>
npx k6-reporter results.json --output report.html
<h1>Post results to Slack</h1>
curl -X POST "$SLACK_WEBHOOK" \
-H "Content-Type: application/json" \
-d "{
\"text\": \"Load Test Results\",
\"blocks\": [{
\"type\": \"section\",
\"text\": {
\"type\": \"mrkdwn\",
\"text\": \"p95: $(jq '.metrics.http_req_duration.values["p(95)"]' results.json)ms
Errors: $(jq '.metrics.http_req_failed.values.rate' results.json)
RPS: $(jq '.metrics.http_reqs.values.rate' results.json)\"
}
}]
}"
---
Frequently Asked Questions
Can I load test my application without a staging environment?
Yes, but with precautions. Use read-only endpoints for high-volume tests. For write endpoints, use dedicated test accounts with data cleanup afterward. Never run destructive load tests against production without the safeguards listed in the production-safe section above.
How many virtual users should my load test simulate?
Start with your actual peak concurrent user count (check analytics). Multiply by 2x for stress testing and 5-10x for breakpoint testing. If you do not know your peak, start with 50 virtual users and increase until you see degradation.
Should I use k6 or Locust?
k6 for most teams. It is faster, uses less memory per virtual user, and the JavaScript syntax is familiar to most developers. Use Locust when you need complex Python logic in test scenarios or when your team is Python-first.
How do I test WebSocket connections under load?
k6 has native WebSocket support. Create a test that opens WebSocket connections, sends messages at realistic intervals, and validates responses. WebSocket connections consume more server resources than HTTP requests, so test with fewer virtual users.
What is the difference between throughput and latency?
Throughput is requests per second (how many). Latency is response time (how fast). High throughput with low latency is the goal. When throughput hits a ceiling, latency starts increasing. This inflection point is your capacity limit. Monitor both during tests. Visit Contact for help with capacity planning.