Pick the tool by team + need, encode thresholds as exit-code gates. Default to k6 for code-first, CI-friendly tests — it has RPS-precise arrival-rate executors and native threshold gates, so it covers most cases. Reach for the others only for the listed reason:
| Tool | Script lang | Reach for it when | Native threshold gate |
|---|
| k6 (default) | JS | CI, scripted, RPS-precise (constant-arrival-rate) | thresholds → exit 99 on breach |
| Locust | Python | dynamic per-user logic, Python shop | --exit-code-on-error + custom |
| Gatling | Scala/Java DSL | JVM teams, rich HTML report | assertions → non-zero exit |
| Artillery | YAML/JS | quick YAML scenarios, serverless | ensure plugin |
| JMeter | XML/GUI | legacy/enterprise, protocol breadth | clunky; prefer above for CI |
k6 with an open model (arrival rate — the correct way to fix RPS and dodge coordinated omission) and SLOs as code:
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
const users = new SharedArray('u', () => JSON.parse(open('./users.json')));
export const options = {
scenarios: {
ramp_to_knee: {
executor: 'ramping-arrival-rate',
startRate: 100, timeUnit: '1s',
preAllocatedVUs: 200, maxVUs: 2000,
stages: [
{ target: 500, duration: '2m' },
{ target: 2000, duration: '5m' },
{ target: 4000, duration: '5m' },
],
},
},
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<800'],
http_req_failed: ['rate<0.005'],
http_reqs: ['rate>1800'],
},
};
export default function () {
const u = users[Math.floor(Math.random() * users.length)];
const r = http.get(`https://staging.internal/feed?u=${u.id}`);
check(r, { 'status 200': (res) => res.status === 200 });
sleep(Math.random() * 2 + 1);
}
Run: k6 run --summary-trend-stats="avg,p(95),p(99),max" test.js.