Load Testing Locally With wrk — Know Your App's Ceiling Before Deploy
“It’s fast enough” is a claim about a single request from one browser on a warm cache. It says nothing about what happens at fifty concurrent users, which is the number that actually decides whether your launch goes well. Ten minutes with wrk on your laptop turns that guess into a number, and the number is usually lower than anyone expected.
Why wrk
ab (ApacheBench) is single-threaded and misreports latency under load. siege is easier but less accurate. k6 and Gatling are excellent and are a project to set up. wrk sits in the middle: one binary, multithreaded, event-driven, accurate percentiles, and a Lua scripting hook for anything non-trivial.
Setup:
brew install wrk # macOS
sudo apt install wrk # Debian/Ubuntu
The First Run
Example:
wrk -t4 -c50 -d30s --latency http://localhost:3000/orders
-t4— threads. Match your CPU cores, or fewer. More threads than cores measures your load generator, not your app.-c50— total connections held open across all threads.-d30s— duration. Under ten seconds and you’re measuring warmup.--latency— print the percentile breakdown. Always pass this.
Output:
Running 30s test @ http://localhost:3000/orders
4 threads and 50 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 412.33ms 187.21ms 1.68s 71.42%
Req/Sec 30.12 11.45 70.00 68.33%
Latency Distribution
50% 381.22ms
75% 502.11ms
90% 661.04ms
99% 1.41s
3621 requests in 30.02s, 42.18MB read
Requests/sec: 120.62
Transfer/sec: 1.40MB
Reading It Properly
Ignore the average. Latency distributions in web applications are long-tailed, and the mean sits somewhere nobody experiences. The numbers that matter are p50 (typical), p90 (a bad day), and p99 (the requests that generate support tickets).
Here, p50 is 381ms and p99 is 1.41 seconds. A 3.7× spread means something is contending — connection pool, GC, a lock, or a slow query that only some requests hit.
Check the standard deviation. +/- Stdev 71.42% means most samples fall outside one standard deviation, which is a signal of a bimodal distribution — two populations of requests, fast and slow. That usually means a cache: hits are fast, misses are slow. Worth confirming before optimizing anything.
Watch for errors. wrk reports socket errors and non-2xx responses separately:
Non-2xx or 3xx responses: 412
Four hundred failures in a load test is not “120 requests per second.” It’s a saturated connection pool returning 500s, and the throughput number is meaningless until you fix it.
Finding the Knee
One run tells you little. A sweep tells you where the app falls over.
Example:
#!/usr/bin/env bash
URL="${1:-http://localhost:3000/orders}"
printf "%-8s %-12s %-12s %-12s\n" "CONNS" "REQ/S" "P50" "P99"
for c in 1 5 10 25 50 100 200; do
result=$(wrk -t4 -c"$c" -d20s --latency "$URL")
rps=$(echo "$result" | awk '/Requests\/sec/ { print $2 }')
p50=$(echo "$result" | awk '/^ *50%/ { print $2 }')
p99=$(echo "$result" | awk '/^ *99%/ { print $2 }')
printf "%-8s %-12s %-12s %-12s\n" "$c" "$rps" "$p50" "$p99"
sleep 5
done
Output:
CONNS REQ/S P50 P99
1 41.20 24.11ms 38.02ms
5 118.44 41.83ms 72.15ms
10 121.07 82.14ms 154.22ms
25 120.91 206.33ms 412.80ms
50 120.62 381.22ms 1.41s
100 119.88 798.45ms 3.22s
200 118.02 1.62s 7.90s
That’s the classic shape. Throughput plateaus at ten connections — around 120 req/s — and everything past that only adds queueing. Latency doubles as connections double while throughput stays flat.
The knee is where you’re saturated. Beyond it you’re not serving more traffic, just making everyone wait longer. Your real capacity here is ~120 req/s, and the answer isn’t more connections — it’s more workers or a faster request.
Testing POST and Authenticated Endpoints
wrk needs Lua for anything past a plain GET.
Example:
-- post_order.lua
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
wrk.headers["Authorization"] = "Bearer " .. os.getenv("API_TOKEN")
wrk.body = '{"account_id": 42, "items": [{"sku": "ABC-1", "qty": 2}]}'
wrk -t4 -c50 -d30s --latency -s post_order.lua http://localhost:3000/api/orders
To vary the request per iteration — which you should, or you’re testing one cached row:
Example:
-- varied.lua
local ids = {}
for i = 1, 1000 do ids[i] = i end
request = function()
local id = ids[math.random(#ids)]
return wrk.format("GET", "/orders/" .. id)
end
response = function(status, headers, body)
if status ~= 200 then
io.write("status " .. status .. ": " .. string.sub(body, 1, 200) .. "\n")
end
end
The response hook printing non-200 bodies is what turns “412 non-2xx responses” into an actionable error message.
Making the Test Mean Something
A load test against a development environment measures the development environment. Four things to fix first:
Run in production mode. Development reloads code on every request, disables caching, and logs verbosely. It is trivially 5–10× slower and the shape of the bottleneck is different.
RAILS_ENV=production \
SECRET_KEY_BASE=$(bin/rails secret) \
RAILS_LOG_LEVEL=warn \
bin/rails server -e production
Use production-shaped data. A hundred rows in a table with no index performs identically to a hundred rows with a perfect index. Seed a realistic volume or the test tells you nothing about your indexing.
Match the server config. Same Puma worker and thread counts as production, same database pool size. WEB_CONCURRENCY and RAILS_MAX_THREADS on your laptop default to something unlike your deployment.
Warm it up. Discard the first run. JIT, connection pools, and caches all need a few seconds.
Pro-Tip: Watch your database connection pool during the test, because it’s the ceiling far more often than Ruby is. Puma with 5 threads across 4 workers wants up to 20 connections; a
pool: 5indatabase.ymlmeans fifteen threads are permanently blocked waiting, and your throughput graph flatlines at exactly the pool size no matter how much CPU is idle. The tell is a plateau that doesn’t move when you add workers, plusActiveRecord::ConnectionTimeoutErrorin the log. RunSELECT count(*) FROM pg_stat_activity WHERE state = 'active';in a loop during the test — if it pins at your pool size while CPU sits at 40%, you found the bottleneck and it’s one line of config, not a code change.
What to Watch While It Runs
The wrk output is one half. The other half is what the machine is doing.
# Terminal 2 — server process
htop -p $(pgrep -d, -f puma)
# Terminal 3 — database activity
watch -n1 'psql -d myapp_production -c "
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;"'
# Terminal 4 — slow queries appearing under load
tail -f log/production.log | grep -E 'Duration: [0-9]{3,}'
Queries that are fine in isolation and slow under concurrency are the most valuable finding a load test produces — they don’t show up in any single-request profile.
Conclusion
wrk gives you a real capacity number for the cost of one brew install and twenty minutes. Sweep the connection count to find the knee rather than running one arbitrary test, read p50/p90/p99 and ignore the mean, and treat any non-2xx count as invalidating the throughput figure. Run in production mode against production-shaped data or the whole exercise measures your development environment. And check the connection pool first — it’s the ceiling more often than the code is, and it’s a one-line fix.
FAQs
Q1: How many threads should I use?
No more than your physical cores, and fewer if you’re running the app on the same machine — the load generator competes with the app for CPU and skews everything. For serious numbers, generate load from a separate machine.
Q2: Why is throughput lower than my production monitoring shows?
Your laptop has fewer cores, no CDN, no load balancer, and is running the database locally. Local load testing finds relative problems — this endpoint is 10× slower than that one — rather than absolute capacity.
Q3: What’s a good p99 target?
Depends entirely on the endpoint. A rough rule for user-facing HTML: p99 under 1 second, p50 under 200ms. API endpoints consumed by other services usually need tighter numbers.
Q4: Should load tests run in CI?
Yes, as a trend rather than a gate — CI runners are noisy and a hard threshold produces flaky builds. Record the numbers per commit and alert on a sustained regression instead.
Q5: When should I move from wrk to k6?
When you need multi-step user journeys, session handling across requests, or assertions on response content. wrk is a hammer for single endpoints; k6 models actual user behaviour.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀