Purpose
Answer "will this hold at target traffic?" with a measurement instead of a guess: script the load the system will actually face, run it against a production-like target, and read the percentiles against the SLO. estimate-at-scale predicts capacity by arithmetic and optimize-performance fixes slowness someone already observed; this skill sits between them and produces the evidence both consume. The deliverable is a verdict per target plus a saved, re-runnable scenario.
Two standing rules, in force for the entire engagement:
- Never load test production. A load test is a self-inflicted denial of service; run it against a production-like environment, and treat a request to point it at production as a mistake to push back on, not an instruction to follow.
- Never test infrastructure the user does not own or have explicit authorization to test. Third-party APIs, shared SaaS tenants, and anything behind someone else's rate limiter are off limits; from the target's side, unauthorized load testing is indistinguishable from an attack.
Workflow
Copy this checklist and track progress:
Load Test Progress:
- [ ] 1. Targets pinned (SLOs, RPS, peak factor, journeys)
- [ ] 2. Tool chosen (project's existing tool, else k6)
- [ ] 3. Scenario scripted (ramp profile, think time, data variety)
- [ ] 4. Target environment confirmed production-like and authorized
- [ ] 5. Results read (p95/p99, error rate, saturation, knee) against targets
- [ ] 6. Outcome routed and baseline saved
1. Derive the scenario from real targets
A load test without a target number produces a graph nobody can pass or fail. Pull the targets from what already exists: SLOs and latency budgets in the spec or the observability design, traffic projections, current production metrics. Where none exist, interrogate for them, offering a recommended default for each so the user confirms rather than composes:
- Steady-state RPS and the peak multiplier (2-5x average is typical for diurnal traffic; launches and campaigns spike harder)
- Latency target as a percentile ("p95 under 300ms"), never an average
- Acceptable error rate under load
- Payload shapes and sizes that match real requests
- The user journeys that matter, in realistic proportion (browse-heavy with occasional checkout, not uniform)
Script journeys, not lone endpoints. A lone-endpoint test exercises one handler in isolation; real traffic exercises sessions, the cache hierarchy, and the write path together, and the interactions between them are where systems actually fall over.
2. Pick the tool pragmatically
Honor what the project already uses: an existing artillery config, locustfile, gatling simulation, or vegeta script means extend it, for the same reason any project convention wins over preference. With no incumbent, default to k6: scriptable JS scenarios, first-class ramp stages, and built-in percentile thresholds that make the run pass or fail itself.
3. Script realistic load
| Element |
Why it must be there |
| Warm-up stage at low rate |
Cold caches, empty connection pools, and JIT make the first minutes unrepresentative; measuring them pollutes the percentiles |
| Gradual ramp |
A step to full load hides where degradation starts; a ramp exposes the knee |
| Sustain at target |
Stable p95/p99 need minutes of steady load, not a drive-by |
| Spike to peak |
Tests surge behavior and recovery, which a smooth ramp never exercises |
| Think time between steps |
Real users pause; zero think time turns N virtual users into a connection-recycling storm no real N users produce |
| Data variety |
Parameterize IDs, search terms, and payloads from a data file; a loop of identical requests measures the cache, not the system |
| Thresholds encoding the SLO |
The script should fail on its own numbers, so a regression run in CI needs no human to read the graph |
4. Run against a production-like target
Production-like means: same instance sizes and limits, production-like data volume (query planners choose different plans on empty tables), and the same topology, including the load balancer and TLS. Run the generator from a separate machine on a fat enough pipe, and watch server-side metrics (CPU, memory, queue depth, pool usage) during the run; the generator's view alone cannot distinguish a slow server from a saturated client.
5. Read the results correctly
- p95/p99, never averages. The average is dragged down by cache hits and hides the tail users complain about.
- Error rate under load, and what the errors are. A fast 503 is not throughput; rising errors with flat latency means load shedding, which may be the designed behavior or the failure, so check which.
- Saturation signals server-side. CPU, connection pool exhaustion, queue depth, memory growth; the resource that saturates first is the real capacity limit and the first target for scaling.
- The knee of the curve. Plot latency against arrival rate from the ramp: the point where latency departs from flat is the effective capacity. Throughput plateauing while latency climbs means the system is past it.
- Verdict against each SLO target, stated as pass or fail with the measured number next to the target.
6. Route the outcome
- Fails latency with a hot path: hand the offending journey and the server-side evidence to optimize-performance; its measure-fix-verify loop takes over from a measured baseline.
- Capacity shortfall (knee below target traffic): feed the measured per-instance throughput back into estimate-at-scale's arithmetic and the spec's scaling design; a measured number always replaces an assumed one.
- Passing run: save the scenario, results, and environment description as a baseline artifact that prepare-for-deploy can reference and future runs can regress against.
- Then soak: run at 70-80% of the knee for hours to catch what short runs cannot: memory creep, fd and connection leaks, disk filling with logs, degrading GC behavior.
Test shapes
| Shape |
Profile |
Question it answers |
| Smoke |
1-2 VUs, a minute |
Does the script and the system work at all? |
| Load |
Ramp to target, sustain |
Does it meet the SLO at expected traffic? |
| Stress |
Ramp past target until failure |
Where is the knee, and what breaks first? |
| Spike |
Sudden jump to peak, drop back |
Does it survive the surge and recover after? |
| Soak |
70-80% of knee, hours |
Does it leak or degrade over time? |
Gotchas
- Load-generator saturation masquerades as server latency. A generator out of CPU, sockets, or bandwidth reports rising response times that are its own queuing. Watch generator-side resource usage, and prove headroom by adding a second generator: if reported latency drops, the first generator was the bottleneck.
- Coordinated omission understates the tail. A closed-loop generator that waits for each response before sending the next slows its own request rate exactly when the server degrades, silently dropping the worst samples. Prefer open-model arrival rates (k6
constant-arrival-rate / ramping-arrival-rate) when measuring latency under load.
- Testing through a CDN or WAF measures the CDN, not the system. Cache hits at the edge flatter the numbers, and the WAF may throttle or block the generator mid-run. Point the test at origin for capacity questions; test through the edge only when edge behavior is itself the question.
- Localhost results mean nothing for production. No real network, no TLS handshakes, no load balancer, dev-mode builds, and toy data volume. Localhost is for debugging the script (the smoke shape), never for the verdict.
Example
A two-step journey in k6 with the realism elements from step 3:
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
// Data variety: without it, every iteration hits the same cache line
const products = new SharedArray('products', () =>
JSON.parse(open('./products.json')));
export const options = {
stages: [
{ duration: '2m', target: 50 }, // warm-up: fill caches and pools
{ duration: '5m', target: 400 }, // ramp: watch for the knee on the way up
{ duration: '10m', target: 400 }, // sustain: percentiles come from here
{ duration: '1m', target: 800 }, // spike: 2x peak factor
{ duration: '2m', target: 0 }, // recovery
],
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<800'], // the SLO, encoded
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const product = products[Math.floor(Math.random() * products.length)];
const res = http.get(`${__ENV.TARGET}/api/products/${product.id}`);
check(res, { 'product loaded': (r) => r.status === 200 });
sleep(1 + Math.random() * 2); // think time: users read before acting
const cart = http.post(
`${__ENV.TARGET}/api/cart`,
JSON.stringify({ productId: product.id, qty: 1 }),
{ headers: { 'Content-Type': 'application/json' } },
);
check(cart, { 'added to cart': (r) => r.status === 201 });
sleep(2 + Math.random() * 3);
}
Run with TARGET pointing at the staging or perf environment, never production.
1---2name: load-test3description: This skill should be used to empirically answer whether a system holds at target traffic by scripting and running load tests and reading the results against SLO targets. It applies when the user says "load test", "stress test", "soak test", "will it handle launch traffic", "how does it behave under load", "test at scale", "find the breaking point", or mentions k6, artillery, locust, gatling, or vegeta. It should not be used for estimating capacity by math with no measurement (use estimate-at-scale), for fixing an already-observed slow path (use optimize-performance), or for designing the SLOs themselves (use spec).4---56## Purpose78Answer "will this hold at target traffic?" with a measurement instead of a guess: script the load the system will actually face, run it against a production-like target, and read the percentiles against the SLO. estimate-at-scale predicts capacity by arithmetic and optimize-performance fixes slowness someone already observed; this skill sits between them and produces the evidence both consume. The deliverable is a verdict per target plus a saved, re-runnable scenario.910Two standing rules, in force for the entire engagement:1112- **Never load test production.** A load test is a self-inflicted denial of service; run it against a production-like environment, and treat a request to point it at production as a mistake to push back on, not an instruction to follow.13- **Never test infrastructure the user does not own or have explicit authorization to test.** Third-party APIs, shared SaaS tenants, and anything behind someone else's rate limiter are off limits; from the target's side, unauthorized load testing is indistinguishable from an attack.1415## Workflow1617Copy this checklist and track progress:1819```text20Load Test Progress:21- [ ] 1. Targets pinned (SLOs, RPS, peak factor, journeys)22- [ ] 2. Tool chosen (project's existing tool, else k6)23- [ ] 3. Scenario scripted (ramp profile, think time, data variety)24- [ ] 4. Target environment confirmed production-like and authorized25- [ ] 5. Results read (p95/p99, error rate, saturation, knee) against targets26- [ ] 6. Outcome routed and baseline saved27```2829### 1. Derive the scenario from real targets3031A load test without a target number produces a graph nobody can pass or fail. Pull the targets from what already exists: SLOs and latency budgets in the spec or the observability design, traffic projections, current production metrics. Where none exist, interrogate for them, offering a recommended default for each so the user confirms rather than composes:3233- Steady-state RPS and the peak multiplier (2-5x average is typical for diurnal traffic; launches and campaigns spike harder)34- Latency target as a percentile ("p95 under 300ms"), never an average35- Acceptable error rate under load36- Payload shapes and sizes that match real requests37- The user journeys that matter, in realistic proportion (browse-heavy with occasional checkout, not uniform)3839Script journeys, not lone endpoints. A lone-endpoint test exercises one handler in isolation; real traffic exercises sessions, the cache hierarchy, and the write path together, and the interactions between them are where systems actually fall over.4041### 2. Pick the tool pragmatically4243Honor what the project already uses: an existing artillery config, locustfile, gatling simulation, or vegeta script means extend it, for the same reason any project convention wins over preference. With no incumbent, default to k6: scriptable JS scenarios, first-class ramp stages, and built-in percentile thresholds that make the run pass or fail itself.4445### 3. Script realistic load4647| Element | Why it must be there |48|---|---|49| Warm-up stage at low rate | Cold caches, empty connection pools, and JIT make the first minutes unrepresentative; measuring them pollutes the percentiles |50| Gradual ramp | A step to full load hides where degradation starts; a ramp exposes the knee |51| Sustain at target | Stable p95/p99 need minutes of steady load, not a drive-by |52| Spike to peak | Tests surge behavior and recovery, which a smooth ramp never exercises |53| Think time between steps | Real users pause; zero think time turns N virtual users into a connection-recycling storm no real N users produce |54| Data variety | Parameterize IDs, search terms, and payloads from a data file; a loop of identical requests measures the cache, not the system |55| Thresholds encoding the SLO | The script should fail on its own numbers, so a regression run in CI needs no human to read the graph |5657### 4. Run against a production-like target5859Production-like means: same instance sizes and limits, production-like data volume (query planners choose different plans on empty tables), and the same topology, including the load balancer and TLS. Run the generator from a separate machine on a fat enough pipe, and watch server-side metrics (CPU, memory, queue depth, pool usage) during the run; the generator's view alone cannot distinguish a slow server from a saturated client.6061### 5. Read the results correctly6263- **p95/p99, never averages.** The average is dragged down by cache hits and hides the tail users complain about.64- **Error rate under load, and what the errors are.** A fast 503 is not throughput; rising errors with flat latency means load shedding, which may be the designed behavior or the failure, so check which.65- **Saturation signals server-side.** CPU, connection pool exhaustion, queue depth, memory growth; the resource that saturates first is the real capacity limit and the first target for scaling.66- **The knee of the curve.** Plot latency against arrival rate from the ramp: the point where latency departs from flat is the effective capacity. Throughput plateauing while latency climbs means the system is past it.67- **Verdict against each SLO target**, stated as pass or fail with the measured number next to the target.6869### 6. Route the outcome7071- **Fails latency with a hot path**: hand the offending journey and the server-side evidence to optimize-performance; its measure-fix-verify loop takes over from a measured baseline.72- **Capacity shortfall (knee below target traffic)**: feed the measured per-instance throughput back into estimate-at-scale's arithmetic and the spec's scaling design; a measured number always replaces an assumed one.73- **Passing run**: save the scenario, results, and environment description as a baseline artifact that prepare-for-deploy can reference and future runs can regress against.74- **Then soak**: run at 70-80% of the knee for hours to catch what short runs cannot: memory creep, fd and connection leaks, disk filling with logs, degrading GC behavior.7576## Test shapes7778| Shape | Profile | Question it answers |79|---|---|---|80| Smoke | 1-2 VUs, a minute | Does the script and the system work at all? |81| Load | Ramp to target, sustain | Does it meet the SLO at expected traffic? |82| Stress | Ramp past target until failure | Where is the knee, and what breaks first? |83| Spike | Sudden jump to peak, drop back | Does it survive the surge and recover after? |84| Soak | 70-80% of knee, hours | Does it leak or degrade over time? |8586## Gotchas8788- **Load-generator saturation masquerades as server latency.** A generator out of CPU, sockets, or bandwidth reports rising response times that are its own queuing. Watch generator-side resource usage, and prove headroom by adding a second generator: if reported latency drops, the first generator was the bottleneck.89- **Coordinated omission understates the tail.** A closed-loop generator that waits for each response before sending the next slows its own request rate exactly when the server degrades, silently dropping the worst samples. Prefer open-model arrival rates (k6 `constant-arrival-rate` / `ramping-arrival-rate`) when measuring latency under load.90- **Testing through a CDN or WAF measures the CDN, not the system.** Cache hits at the edge flatter the numbers, and the WAF may throttle or block the generator mid-run. Point the test at origin for capacity questions; test through the edge only when edge behavior is itself the question.91- **Localhost results mean nothing for production.** No real network, no TLS handshakes, no load balancer, dev-mode builds, and toy data volume. Localhost is for debugging the script (the smoke shape), never for the verdict.9293## Example9495A two-step journey in k6 with the realism elements from step 3:9697```javascript98import http from 'k6/http';99import { check, sleep } from 'k6';100import { SharedArray } from 'k6/data';101102// Data variety: without it, every iteration hits the same cache line103const products = new SharedArray('products', () =>104 JSON.parse(open('./products.json')));105106export const options = {107 stages: [108 { duration: '2m', target: 50 }, // warm-up: fill caches and pools109 { duration: '5m', target: 400 }, // ramp: watch for the knee on the way up110 { duration: '10m', target: 400 }, // sustain: percentiles come from here111 { duration: '1m', target: 800 }, // spike: 2x peak factor112 { duration: '2m', target: 0 }, // recovery113 ],114 thresholds: {115 http_req_duration: ['p(95)<300', 'p(99)<800'], // the SLO, encoded116 http_req_failed: ['rate<0.01'],117 },118};119120export default function () {121 const product = products[Math.floor(Math.random() * products.length)];122123 const res = http.get(`${__ENV.TARGET}/api/products/${product.id}`);124 check(res, { 'product loaded': (r) => r.status === 200 });125 sleep(1 + Math.random() * 2); // think time: users read before acting126127 const cart = http.post(128 `${__ENV.TARGET}/api/cart`,129 JSON.stringify({ productId: product.id, qty: 1 }),130 { headers: { 'Content-Type': 'application/json' } },131 );132 check(cart, { 'added to cart': (r) => r.status === 201 });133 sleep(2 + Math.random() * 3);134}135```136137Run with `TARGET` pointing at the staging or perf environment, never production.