7. Open-Source Load Testing Tools — Walkthroughs
Sections 1–6 gave you the vocabulary and the scenario patterns; from here on it's hands-on. This section is a tool-by-tool walkthrough — pick the one that matches your target (raw HTTP throughput, distributed scripted scenarios, or raw TCP/UDP) rather than reading all of it linearly.
7.1 wrk (C) — high-performance HTTP benchmarking
Written in C on top of an epoll/kqueue event loop with a small Lua scripting layer. Best for raw HTTP/1.1 throughput/latency measurement with minimal generator overhead.
# install (macOS)
brew install wrk
# 30 seconds, 12 threads, 400 concurrent connections, print latency percentiles
wrk -t12 -c400 -d30s --latency http://localhost:8080/api/products
Custom Lua script to POST JSON with per-request randomized bodies (models varied request content, not identical repeated requests):
-- post.lua
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
request = function()
local id = math.random(1, 100000)
local body = string.format('{"user_id": %d, "item": "sku-%d"}', id, id % 500)
return wrk.format(nil, nil, nil, body)
end
wrk -t8 -c200 -d60s -s post.lua --latency http://localhost:8080/api/orders
Note: plain wrk is a closed-loop tool (§3.4) — for open-loop, constant-rate
testing, use its fork wrk2, which adds -R <rate> and corrects for
"coordinated omission" (a bias where slow responses cause the tool to
under-sample exactly the slow period, making reported percentiles look
artificially good).
7.2 hey (Go) — simple, single-binary HTTP load generator
go install github.com/rakyll/hey@latest
hey -z 30s -c 200 -m POST -T "application/json" \
-d '{"user_id":1,"item":"sku-1"}' \
http://localhost:8080/api/orders
Output includes a latency histogram and status code distribution — good for quick checks without writing a script.
7.3 vegeta (Go) — attack/report pipeline, built for open-loop rate testing
vegeta is designed around Little's Law-aware, constant-arrival-rate testing and
a separate report step, which maps directly onto §10 (report generation).
go install github.com/tsenart/vegeta@latest
# targets file: METHOD URL pairs
cat <<EOF > targets.txt
GET http://localhost:8080/api/products/1
GET http://localhost:8080/api/products/2
POST http://localhost:8080/api/orders
Content-Type: application/json
@body.json
EOF
# open-loop attack at fixed 500 req/s for 60s, save results
vegeta attack -targets=targets.txt -rate=500 -duration=60s > results.bin
# text report: min/mean/p50/p95/p99/max, success ratio
vegeta report results.bin
# histogram of latency buckets
vegeta report -type='hist[0,10ms,50ms,100ms,200ms,500ms,1s]' results.bin
# plot: interactive HTML latency-over-time chart
vegeta plot results.bin > plot.html
7.4 k6 (Go core, JavaScript scripting) — modern scriptable load testing
The most widely adopted modern tool; scenarios, thresholds (pass/fail SLAs baked into the test), and multiple executor types (constant VUs, ramping VUs, constant-arrival-rate, ramping-arrival-rate) make it well-suited to every pattern in §4.3.
// flash_sale.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate('errors');
const checkoutLatency = new Trend('checkout_latency');
export const options = {
scenarios: {
browse: {
executor: 'ramping-arrival-rate',
startRate: 50,
timeUnit: '1s',
preAllocatedVUs: 2000,
maxVUs: 5000,
stages: [
{ target: 50, duration: '10s' }, // baseline
{ target: 5000, duration: '15s' }, // flash-sale spike
{ target: 5000, duration: '30s' }, // sustained peak
{ target: 0, duration: '10s' }, // drain
],
exec: 'browse',
},
checkout: {
executor: 'ramping-arrival-rate',
startRate: 2,
timeUnit: '1s',
preAllocatedVUs: 200,
maxVUs: 500,
stages: [
{ target: 2, duration: '10s' },
{ target: 300, duration: '15s' },
{ target: 300, duration: '30s' },
{ target: 0, duration: '10s' },
],
exec: 'checkout',
},
},
thresholds: {
'http_req_duration{scenario:browse}': ['p(95)<300'],
'http_req_duration{scenario:checkout}': ['p(99)<1500'],
errors: ['rate<0.01'],
},
};
export function browse() {
const res = http.get('http://localhost:8080/api/products/42');
check(res, { 'status is 200': (r) => r.status === 200 });
errorRate.add(res.status !== 200);
}
export function checkout() {
const payload = JSON.stringify({ user_id: __VU, item: 'sku-limited-42' });
const res = http.post('http://localhost:8080/api/orders', payload, {
headers: { 'Content-Type': 'application/json' },
});
checkoutLatency.add(res.timings.duration);
check(res, { 'checkout ok or sold-out': (r) => [200, 409].includes(r.status) });
errorRate.add(![200, 409].includes(res.status));
}
k6 run --out json=results.json flash_sale.js
k6's thresholds block is the key feature for CI gating: the run exits non-zero
if p95/p99 SLAs are violated, so this drops directly into a CI/CD pipeline as a
performance regression gate.
7.5 Locust (Python) — code-first, stateful user-behavior simulation
Best when scenarios are inherently stateful/sequential (login → browse → hold seat → pay, §6.2) and you want to express that logic in a real programming language rather than a DSL.
# locustfile.py
from locust import HttpUser, task, between, events
import random
class ShopperUser(HttpUser):
wait_time = between(1, 3) # closed-loop "think time" between actions
def on_start(self):
resp = self.client.post("/api/login", json={"user": f"user{random.randint(1,100000)}"})
self.token = resp.json().get("token")
self.client.headers.update({"Authorization": f"Bearer {self.token}"})
@task(9)
def browse_product(self):
product_id = random.randint(1, 5000)
self.client.get(f"/api/products/{product_id}", name="/api/products/[id]")
@task(1)
def checkout(self):
payload = {"item": "sku-limited-42", "qty": 1}
with self.client.post("/api/orders", json=payload, catch_response=True) as resp:
if resp.status_code == 409:
resp.success() # sold out is an expected outcome, not a failure
elif resp.status_code != 200:
resp.failure(f"unexpected status {resp.status_code}")
@events.quitting.add_listener
def _(environment, **kwargs):
if environment.stats.total.fail_ratio > 0.01:
environment.process_exit_code = 1 # CI gate
pip install locust
# headless, distributed-ready, generates HTML + CSV reports (see §10.3)
locust -f locustfile.py --headless \
--users 5000 --spawn-rate 200 --run-time 5m \
--host http://localhost:8080 \
--html report.html --csv results
For scale beyond one machine's CPU (Python's GIL limits single-process concurrency), Locust supports a distributed mode:
# on the master
locust -f locustfile.py --master
# on N worker machines/containers
locust -f locustfile.py --worker --master-host=<master-ip>
7.6 Goose (Rust) — Locust-inspired, for CPU-efficient high-concurrency tests
When Python's per-process concurrency ceiling becomes the bottleneck (i.e., your load generator becomes the thing under test instead of the target service), Goose gives Locust-style ergonomics with Rust/Tokio throughput.
// Cargo.toml: goose = "0.17"
use goose::prelude::*;
async fn browse_product(user: &mut GooseUser) -> TransactionResult {
let product_id = rand::random::<u32>() % 5000 + 1;
let _goose = user.get(&format!("/api/products/{product_id}")).await?;
Ok(())
}
async fn checkout(user: &mut GooseUser) -> TransactionResult {
let body = serde_json::json!({ "item": "sku-limited-42", "qty": 1 });
let _goose = user.post_json("/api/orders", &body).await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(
scenario!("Shopper")
.register_transaction(transaction!(browse_product).set_weight(9)?)
.register_transaction(transaction!(checkout).set_weight(1)?),
)
.execute()
.await?;
Ok(())
}
cargo run --release -- --host http://localhost:8080 \
--users 20000 --run-time 5m --report-file report.html
7.7 ghz (Go) — gRPC load testing
go install github.com/bojand/ghz/cmd/ghz@latest
ghz --insecure \
--proto ./order.proto --call order.OrderService.CreateOrder \
-d '{"user_id":1,"item":"sku-1"}' \
-c 200 -n 100000 --connections 20 \
--format html -o report.html \
localhost:50051
--connections 20 with -c 200 models 200 concurrent callers spread over 20
HTTP/2 connections (10 streams/connection) — mirroring §5.4's guidance to avoid
funneling all concurrency through one connection.
7.8 Apache JMeter (Java) — GUI + enterprise protocol coverage
Still widely used where teams need a GUI test-plan builder, non-HTTP protocol support (JDBC, JMS, LDAP, FTP), or existing organizational JMeter test-plan investment. CLI mode for CI:
jmeter -n -t flash_sale.jmx -l results.jtl -e -o report_dir/
-e -o auto-generates an HTML dashboard report from the results file (see §10).
7.9 iperf3 (C) — pure network layer, no application involved
Before blaming your application, prove what the network itself can do. iperf3
measures raw TCP/UDP throughput and jitter between two hosts with no HTTP/app
overhead — the baseline every other number in this guide should be compared
against.
# on the server
iperf3 -s
# on the client: 30s TCP throughput test
iperf3 -c server_ip -t 30
# UDP test with specified bandwidth, reports jitter + packet loss - useful for
# validating the "network" side of a video-streaming or VoIP load test (§6.5)
iperf3 -c server_ip -u -b 50M -t 30
If iperf3 shows you can only push 20 Mbps between two regions, no amount of
API optimization will make a 100 Mbps application-level load test pass — fix
network path/MTU/routing first.
7.10 Tool selection cheat-sheet
| Need | Tool |
|---|---|
| Quick single-endpoint smoke test | hey, ab |
| Raw max-throughput HTTP/1.1 benchmark | wrk |
| Open-loop constant-rate testing, avoid coordinated omission | wrk2, vegeta, k6 arrival-rate executor |
| Complex, stateful, multi-step user journeys | Locust (Python), Goose (Rust), k6 (JS) |
| CI-gated performance regression testing | k6 (thresholds), Locust (process_exit_code) |
| gRPC services | ghz |
| Distributed, massive-scale generation | Locust distributed mode, k6 Cloud/Operator, Goose |
| Raw network baseline (no app layer) | iperf3 |
| GUI test-plan / enterprise protocols | JMeter |
| Network condition simulation (latency/loss/jitter) | tc netem, Toxiproxy, Comcast |