Write a Realistic Load Test Scenario
Model arrival rate, ramp-up, and think time in k6, assert p95 latency and error thresholds, and run it against staging before release.
Task
Prove the service meets latency and error targets at the expected traffic shape, not just with a single request loop.
When to use this
A launch campaign triples traffic. The service passes a smoke test at ten requests per second but collapses at two hundred because the test never modeled concurrency or think time.
Prerequisites
- k6 0.50 or newer
- A staging environment with production-like data volume
- A dataset of realistic identifiers, not a single hot row
Steps
1. Define an arrival-rate scenario with a ramp, a plateau, and explicit thresholds.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
browse: {
executor: 'ramping-arrival-rate',
startRate: 20,
timeUnit: '1s',
preAllocatedVUs: 50,
maxVUs: 300,
stages: [
{ target: 200, duration: '2m' },
{ target: 200, duration: '5m' },
{ target: 0, duration: '1m' },
],
},
},
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<800'],
http_req_failed: ['rate<0.01'],
},
};
Expected output: 200 requests per second for five minutes; the run fails if p95 exceeds 300 ms or errors exceed 1 percent.
2. Script a short user journey with think time instead of hammering one endpoint.
export default function () {
const list = http.get(`${__ENV.BASE_URL}/api/products?limit=20`);
check(list, { 'list 200': (r) => r.status === 200 });
const id = list.json('data.0.id');
const detail = http.get(`${__ENV.BASE_URL}/api/products/${id}`);
check(detail, { 'detail 200': (r) => r.status === 200 });
sleep(Math.random() * 2 + 1);
}
Expected output: Each virtual user browses a list, opens one detail page, and waits 1 to 3 seconds, matching real sessions.
3. Spread requests over a realistic identifier set so caches and indexes behave like production.
import { SharedArray } from 'k6/data';
const ids = new SharedArray('product ids', () => JSON.parse(open('./ids.json')));
const id = ids[Math.floor(Math.random() * ids.length)];
Expected output: Traffic hits thousands of distinct products instead of one row that stays in the buffer cache.
4. Run against staging and record the summary with the release candidate.
k6 run -e BASE_URL=https://staging.example.com --summary-export=reports/load-2026-10-21.json load/products.jsExpected output: Thresholds pass with p95 around 210 ms, p99 around 540 ms, and an error rate under 0.5 percent.
5. Compare the summary with the previous release instead of reading a single number in isolation.
node -e "const a=require('./reports/load-2026-10-21.json'); const b=require('./reports/load-2026-09-15.json'); console.log('p95', b.metrics.http_req_duration.values['p(95)'], '->', a.metrics.http_req_duration.values['p(95)'])"Expected output: p95 195 -> 210, a 7 percent regression that is reviewed and either accepted or fixed before release.
Troubleshooting
Problem
Thresholds fail only in the first minute
Solution
Caches and connection pools are cold. Add an unmeasured warmup stage, then start the ramp so the plateau reflects steady state.
Problem
The load generator runs out of CPU before the service does
Solution
Watch k6 CPU and the dropped_iterations metric. Distribute the test across multiple instances or use a managed runner when the target rate is high.
Problem
Results vary wildly between runs
Solution
Pin the dataset, the instance type, and the time of day. Run three times and compare medians; a single run is an anecdote, not a baseline.
Limitations
- Staging capacity rarely matches production, so results are relative and must be compared against the same environment.
- Synthetic journeys cannot model every real user path or third-party latency.
- Load tests write data and consume quota; isolate the environment and reset it afterwards.
Smoke tests do not predict collapse
A loop that sends one request after another measures a single user with no think time and no concurrency. Real traffic arrives as a rate, holds connections while users read, and hits many different records. A service can look healthy at ten requests per second and fall over at two hundred because a pool, a lock, or a cache key degrades non-linearly. The scenario must reproduce the shape of production traffic, not just its endpoint list.
Model arrival rate, not virtual users
ramping-arrival-rate expresses intent directly: “send 200 requests per second”. k6 allocates virtual users as needed, and dropped_iterations tells you when the generator could not keep up. Constant-VU executors couple load to response time, so a slow service accidentally receives less traffic and the test hides the problem. Use arrival rate for capacity questions and VU-based executors only when modeling a fixed population of clients.
executor: 'ramping-arrival-rate',
stages: [
{ target: 200, duration: '2m' },
{ target: 200, duration: '5m' },
{ target: 0, duration: '1m' },
],
Script a journey, not an endpoint
A list request followed by a detail request exercises serialization, caching, and database access the way a person does. Add think time between steps so connections are held realistically, and vary identifiers so the working set exceeds memory. Checks should assert business conditions, such as a non-empty list and a matching id, not just HTTP 200.
Thresholds are the contract
Thresholds turn a load test into a pass/fail gate. Set them from the SLO, not from the previous run: if the product promises p95 under 300 ms, that number belongs in the options. Keep a second, looser threshold for p99 to catch tail problems, and always include an error-rate threshold so a fast but failing service cannot pass.
Compare releases, not absolutes
A single run in a shared staging environment is noisy. Export the summary, store it with the release, and compare the same metric across runs. A 7 percent p95 regression after a dependency upgrade is actionable even when both runs are within the threshold. Treat the baseline as a trend line, and rerun when a change is large enough to matter.