/ Tags: DEVELOPER TIPS / Categories: TIPS

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: 5 in database.yml means 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, plus ActiveRecord::ConnectionTimeoutError in the log. Run SELECT 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More