wrk is an open-source HTTP benchmarking tool written in C that keeps a fixed number of connections open to one URL for a set time and reports latency and requests per second. A first benchmark is one line: wrk -t2 -c10 -d10s http://127.0.0.1:8080/, which runs 2 threads holding 10 connections for 10 seconds.
This wrk load testing tutorial runs wrk 4.2.0 against a small local API and shows what it printed. Four results surprised us: a 3-second endpoint reported 0 µs latency, a server that never answered produced no error at all, every run with failing requests exited with code 0 unless a script made it fail, and a 3-second server freeze changed the 99th percentile from about 210 ms to 2.8 s depending on one flag.
Key Takeaways
- wrk is a closed-loop tool: each connection sends its next request only after the last response arrives, so
-csets how many requests are in flight, not a request rate. - A response slower than
--timeout(2 seconds by default) is counted as a timeout and left out of every latency figure. Set--timeoutabove the slowest response you expect. - Unless a Lua script calls
os.exit(1), wrk exits with code 0 whenever the run starts, even when every response was an HTTP error (all 415s or all 405s in our tests). For a CI gate, make that call in adone()function. - POST bodies, methods and most custom requests need a Lua script. A
Content-Typeheader on its own does not turn a request into a POST. - wrk 4 already adjusts its latency percentiles for coordinated omission after the run. wrk2 goes further by sending at a constant rate, but it has not changed since 2019.
What is wrk?
wrk is a command-line HTTP benchmarking tool by Will Glozer, released under a modified Apache 2.0 licence. It runs several threads, each with its own event loop (epoll on Linux, kqueue on macOS), so one machine can push a lot of requests. A LuaJIT scripting layer can change the request, read responses and print a custom report.
The current release is 4.2.0 from February 2021, and the repository has had no commits since. That is less of a problem than it sounds: wrk does one job, and with no code changes the behaviour described here, read from its 4.2.0 source and confirmed by running it, will not drift. It speaks HTTP/1.1 only, over plain TCP or TLS.
How do you install wrk?
Use your package manager on macOS and Linux: Homebrew and Alpine ship 4.2.0, Debian and Ubuntu 4.1.0. On Windows there is no native build, so run it inside WSL. Check the install with wrk -v, which prints the version and the event backend it uses.
| System | Command | Version packaged (October 2026) |
|---|---|---|
| macOS | brew install wrk | 4.2.0 |
| Debian, Ubuntu | sudo apt install wrk | 4.1.0 |
| Alpine | apk add wrk | 4.2.0 |
| Windows | the Ubuntu command inside WSL | 4.1.0 |
| Anything else | git clone the repository and run make | 4.2.0 |
Building from source needs GNU make, and the wrk source tree includes the LuaJIT and OpenSSL versions it needs. Our runs used the Homebrew build on an Apple M1 Max, which printed wrk 4.2.0 [kqueue] Copyright (C) 2012 Will Glozer.
How do you run your first wrk benchmark?
Give wrk a thread count with -t, a connection count with -c, a duration with -d and a full URL including http:// or https://. Without options it runs 2 threads, 10 connections and 10 seconds. Add --latency to print a percentile table under the summary.
wrk -t2 -c10 -d10s --latency http://127.0.0.1:18080/api/products
Our target was a small Node.js API on the same laptop, with a product list, a login, an endpoint that needs a token and a few deliberately slow or failing routes. The product list returns 886 bytes of JSON. wrk printed:
Running 10s test @ http://127.0.0.1:18080/api/products
2 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.50ms 5.56ms 82.24ms 95.82%
Req/Sec 14.46k 7.65k 28.50k 58.50%
Latency Distribution
50% 236.00us
75% 589.00us
90% 2.29ms
99% 30.81ms
287936 requests in 10.03s, 291.35MB read
Requests/sec: 28701.99
Transfer/sec: 29.04MB
Run it more than once. Two identical 10-second runs either side of this one reported 18,281 and 12,363 requests per second, because the laptop was busy with other work and wrk, the server and everything else shared its CPU. On a shared machine, the spread between runs tells you more than any single number.
Each thread handles connections / threads connections, rounded down (-t3 -c10 opens 9), so -c must be at least -t. wrk -t4 -c2 stops with number of connections must be >= threads and exit code 1.
How do you read wrk output?
Start with the last lines: total requests, then Socket errors and Non-2xx or 3xx responses, which appear only when there are some. Then read Requests/sec and the latency percentiles. The Thread Stats table is the hardest part to read, because its two rows measure different things.
- Latency is per request: the time from sending the request to receiving the full response. Avg, Stdev and Max cover every completed request that arrived within the timeout, plus the estimated samples wrk adds after the run (see coordinated omission below).
+/- Stdevis the share of requests within one standard deviation of the mean. - Req/Sec is per thread, not for the whole run. wrk samples each thread’s rate every 100 ms, so with 2 threads the average of 14.46k is about half of the 28,702 total.
- Latency Distribution lists the 50th, 75th, 90th and 99th percentiles. Read these rather than the average: the average of 1.50 ms sits far above the median of 236 µs because a few slow requests pull it up, which is why percentiles say more than averages.
- Requests/sec is completed requests divided by the run time, and Transfer/sec is bytes read divided by the run time, headers included.
Non-2xx or 3xx responses counts responses with a status code above 399. A redirect is not counted, and wrk does not follow it: our endpoint that returns HTTP 302 reported 44,077 requests per second and no errors, which measured the redirect, not the page behind it.
How do you send POST requests and headers with wrk?
Add headers with -H, which you can repeat. For a POST, put the method, body and content type in a Lua script and pass it with -s. wrk computes Content-Length for you and sends the same request on every connection, so a static script like this costs no extra CPU.
-- post.lua
wrk.method = "POST"
wrk.body = '{"user":"demo","password":"demo123"}'
wrk.headers["Content-Type"] = "application/json"
wrk -t2 -c10 -d5s -s post.lua http://127.0.0.1:18080/api/login
wrk -t2 -c10 -d5s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:18080/api/me
Both runs completed without errors. We captured what wrk sent with a listener that saves the raw bytes: two HTTP/1.1 requests. The POST carried the script’s Content-Type, Content-Length: 36 and Host, plus the -H header we added; a plain GET carried only Host. Neither carried anything else. wrk sends no User-Agent, Accept or Accept-Encoding, so a server that compresses responses for browsers sends wrk the uncompressed version. Add -H "Accept-Encoding: gzip" if you want to measure what browsers receive.
Two mistakes failed every request in our tests, and wrk exited with code 0 both times:
| What we ran | Result |
|---|---|
The POST script without the Content-Type line | 123,849 of 123,849 responses were HTTP 415 |
-H "Content-Type: application/json" with no script | wrk sent GETs; 143,263 of 143,263 were HTTP 405 |
How do you send different requests with a Lua script?
Define a request() function that returns a request string built with wrk.format(method, path, headers, body). wrk calls it before every request, so each one can use a different path, ID or body. Building a string per request costs CPU on the load generator, and the wrk documentation suggests building the request strings in init() when you need maximum throughput.
-- ids.lua: a random product ID on every request
math.randomseed(os.time())
request = function()
return wrk.format(nil, "/api/products?id=" .. math.random(1, 1000))
end
wrk’s Lua API has six hooks. Four run inside every thread: init() when the thread starts, delay() for a pause in milliseconds before each request, request(), and response(status, headers, body) after each response. The other two run in a separate Lua state that sends no requests: setup(), called once per thread before the run, and done(summary, latency, requests), called once at the end. Defining response() makes wrk parse every header and body, so leave it out unless you need it.
To count status codes, store them in response() and add them up in done(). Each thread runs its own Lua state and done() runs in another, so done() collects the counts with thread:get():
-- status.lua
counts = {}
response = function(status, headers, body)
counts[status] = (counts[status] or 0) + 1
end
local threads = {}
setup = function(thread) table.insert(threads, thread) end
done = function(summary, latency, requests)
local total = {}
for _, t in ipairs(threads) do
for code, n in pairs(t:get("counts")) do total[code] = (total[code] or 0) + n end
end
for code, n in pairs(total) do io.write(string.format("HTTP %d: %d\n", code, n)) end
end
Against an endpoint that fails one request in ten, this printed HTTP 200: 13041 and HTTP 500: 1517, matching wrk’s own Non-2xx or 3xx responses: 1517. Our first version declared counts with local, and the run ended with PANIC: unprotected error in call to Lua API and exit code 1: thread:get() reads only globals.
Why does wrk show 0 µs latency for a slow endpoint?
Because wrk drops any response slower than --timeout from its latency statistics and counts it as a timeout instead. The default is 2 seconds. An endpoint that always takes 3 seconds therefore reports Latency 0.00us, a percentile table of zeros and Socket errors: ... timeout 12, while every request did complete.
wrk sizes its latency histogram from the timeout when it starts, and a response that does not fit is counted under timeout instead of in any latency figure, though it still counts toward the request total and Requests/sec. Nor is a slow request ever cancelled: the timeout is only applied when a response arrives. Our three runs against slow endpoints, with 1 thread and 4 connections (10 seconds for the 3-second endpoint, 5 seconds for the one that never answers):
| Endpoint and flag | Latency wrk reported | Timeouts | Exit code |
|---|---|---|---|
3-second response, default --timeout | 0.00 µs (all percentiles) | 12 | 0 |
3-second response, --timeout 5s | 3.00 to 3.01 s (all percentiles) | 0 | 0 |
| No response ever | 0 requests, no error line | 0 | 0 |
The last row is the one to remember: a server that accepts connections and never answers produced 0 requests in 5.04s and no Socket errors line at all. Set --timeout above the slowest response you expect, and treat a request count of zero as a failure.
What does wrk’s -c actually measure?
-c is the number of requests in flight. It sets neither a number of users nor a request rate. Each connection waits for its response before sending the next request, so when the server slows down, wrk slows down with it. Past the server’s capacity, adding connections only lengthens the queue and the latency.
We measured this against an endpoint with a pool of 4 workers that each spend 25 ms per request, a ceiling of 160 requests per second. Each row is a separate 8-second run:
-c | Requests/sec | Average latency | 99th percentile |
|---|---|---|---|
| 1 | 37.96 | 26.32 ms | 29.79 ms |
| 2 | 74.48 | 26.81 ms | 33.83 ms |
| 4 | 150.36 | 26.58 ms | 30.29 ms |
| 8 | 152.07 | 52.47 ms | 62.94 ms |
| 16 | 151.99 | 104.64 ms | 112.42 ms |
| 32 | 149.40 | 210.96 ms | 222.04 ms |
| 64 | 148.99 | 418.01 ms | 519.70 ms |
Throughput doubled up to 4 connections, then stayed near 150 a second while each doubling of -c doubled the latency. The rows also follow Little’s Law: requests per second times average latency gives the number in flight, 151.99 × 0.10464 s ≈ 16 at -c 16. To choose -c from real traffic, start from your arrival rate and response time with a concurrent users calculation. ApacheBench’s -c behaved the same way against an identical endpoint, because it also waits for each response.
Does wrk suffer from coordinated omission?
Partly. Coordinated omission is the gap a closed-loop tool leaves when the server stalls: connections stop sending, so the stall produces a handful of slow samples instead of thousands. wrk 4 compensates after the run, by adding estimated samples for the requests that a stalled connection would have sent at its average pace.
The correction is in wrk’s source: it divides the run time by the requests per connection to get an expected interval, and for every recorded latency at least twice that interval it adds samples at one interval shorter, then two intervals shorter, and so on while the value stays above one interval. To see it work, we froze our test server’s event loop four seconds into a 10-second run with 10 connections, twice for each setting:
| Server freeze | --timeout | 99th percentile | Max | Timeouts |
|---|---|---|---|---|
| none | default | 211 ms, 225 ms | 321 ms, 363 ms | 0 |
| 1 second | default | 833 ms, 824 ms | 1.00 s, 0.995 s | 0 |
| 3 seconds | default | 204 ms, 215 ms | 284 ms, 374 ms | 10, 10 |
| 3 seconds | 10 s | 2.85 s, 2.83 s | 3.03 s, 3.00 s | 0 |
The 1-second freeze left 10 slow requests among about 40,000, too few to move a 99th percentile on their own, yet the percentile rose to 830 ms: that is the correction at work. With a 3-second freeze and the default timeout, the 10 stalled requests became timeouts, the correction never saw them, and the 99th percentile looked like a run with no freeze at all. The baseline is high for a local endpoint because the laptop was under heavy load from other work during these runs.
wrk2, a fork by Gil Tene, takes a different route: -R sets a constant request rate and latency is measured from when each request should have been sent. That is closer to how real visitors arrive, but wrk2’s last commit was in September 2019 and it is not in Homebrew’s core formulae, so you build it from source.
Can you use wrk in a CI pipeline?
Yes, but its exit code will not fail a build on its own. Unless a script exits with an error code, wrk returns 0 whenever a run starts, whatever the responses were, and non-zero for start-up problems such as an invalid URL or a refused connection. Put the pass rules in a Lua done() function and call os.exit(1) when one fails.
-- gate.lua: exit 1 on HTTP errors, socket errors, no responses or a slow p99
local max_p99_ms = tonumber(os.getenv("MAX_P99_MS") or "500")
done = function(summary, latency, requests)
local e = summary.errors
local socket = e.connect + e.read + e.write + e.timeout
local p99 = latency:percentile(99) / 1000
io.write(string.format("gate: %d requests, %d HTTP errors, %d socket errors, p99 %.1f ms\n",
summary.requests, e.status, socket, p99))
if summary.requests == 0 or e.status > 0 or socket > 0 or p99 > max_p99_ms then
io.write("gate: FAILED\n")
os.exit(1)
end
end
We ran it against four endpoints, each with 2 threads and 10 connections for 4 seconds. The product list exited 0. The endpoint with occasional 500s exited 1 with 1,416 HTTP errors, the 3-second endpoint exited 1 with 10 socket errors, all timeouts, and the endpoint that never answers exited 1 with 0 requests. Without the script, all four would have passed. The other exit codes we saw:
| What we ran | What wrk printed | Exit code |
|---|---|---|
| A port with nothing listening | unable to connect to 127.0.0.1:18099 Connection refused | 1 |
A URL without http:// | invalid URL: and the usage text | 1 |
-c lower than -t | number of connections must be >= threads | 1 |
| A URL with no path | requests /, which on our API returned 404 to every request | 0 |
ApacheBench aborted on localhost in our tests, because the name resolved to the IPv6 address first and our server listened only on IPv4. wrk handled it without trouble: before starting, it tries every address the name resolves to and keeps the ones that accept a connection.
Should you use keep-alive with wrk?
Yes, for almost every test. wrk always uses HTTP/1.1 keep-alive and reuses each connection for the next request, which matches browsers and most API clients. If the server closes the connection, or you send Connection: close, wrk reconnects before the next request, and the test then measures connection setup as well as the endpoint.
In two pairs of 5-second runs against the product list (2 threads, 10 connections, while the laptop was under the same heavy load as the freeze runs), adding -H "Connection: close" cut throughput from 3,793 to 2,245 and from 4,822 to 2,039 requests per second. Against an HTTPS server each reconnect also costs a TLS handshake. Leave keep-alive on unless your real clients do not reuse connections.
When is wrk not enough?
wrk answers one question well: how much load can this endpoint take from this machine. It cannot ramp the load up over time, follow a multi-step journey with state passed between steps without hand-written Lua, send traffic from several regions, or show results while the test is running.
For scripted journeys, our k6 vs JMeter vs Gatling vs Locust comparison covers four tools; for a fixed request rate from the command line, there is Vegeta. LoadFocus runs load tests from 25+ cloud regions, with tests built in the browser or uploaded as JMeter or k6 scripts, and keeps every run for comparison. The wrk alternative page compares the two, including the cases where wrk remains the better fit, such as local server tuning and quick single-machine benchmarks. For tests beyond one machine, see cloud load testing.
Frequently Asked Questions
What is the difference between threads and connections in wrk?
Threads are operating-system threads that each run an event loop. Connections are the HTTP connections held open, split evenly across the threads. Connections decide how many requests are in flight; threads decide how many CPU cores wrk can use to send them. Set threads to at most the number of cores and connections to the load you want.
Is wrk better than ApacheBench?
For raw request rate, usually yes: wrk uses several threads and HTTP/1.1 keep-alive by default, while ApacheBench runs in one process and sends HTTP/1.0. Both wait for each response before sending the next request. ApacheBench sends POST bodies from a file without scripting, and wrk needs a short Lua script for them.
Does wrk support HTTP/2?
No. wrk sends HTTP/1.1 requests over plain TCP or TLS and has no option for HTTP/2 or HTTP/3. In our tests, every request wrk sent started with an HTTP/1.1 request line. To test HTTP/2 behaviour, use a tool that supports it, such as Vegeta or k6.
Why does wrk report timeouts when my server answered?
wrk counts any response slower than its timeout, 2 seconds by default, as a timeout and leaves it out of the latency figures, even though the response arrived. Raise the limit with the —timeout option, for example —timeout 10s, so that slow responses appear in the percentiles instead.
What is the difference between wrk and wrk2?
wrk2 is a fork of wrk that sends requests at a constant rate set with its -R option and measures latency from when each request should have been sent. wrk keeps a fixed number of connections busy and corrects its percentiles after the run. wrk2 has not changed since 2019 and is not in Homebrew’s core formulae, so on macOS you build it from source.
Sources
- Options, output, scripting API and benchmarking tips: github.com/wg/wrk, README and SCRIPTING, October 2026.
- Releases (4.0.0 March 2015, 4.1.0 January 2018, 4.2.0 February 2021) and changelog: github.com/wg/wrk, tags and CHANGES, October 2026.
- Timeout handling, error counters, exit codes, the latency correction and address resolution: github.com/wg/wrk,
src/wrk.c,src/stats.candsrc/wrk.luaat the 4.2.0 commit, October 2026. - Build requirements: github.com/wg/wrk, INSTALL, October 2026.
- Packaged versions: formulae.brew.sh (
wrk), packages.ubuntu.com (wrk, noble), packages.debian.org (wrk, trixie), pkgs.alpinelinux.org (wrk, edge), October 2026. - Constant-rate load and coordinated omission: github.com/giltene/wrk2, README, last commit 24 September 2019, October 2026.
- Test runs: wrk 4.2.0 (Homebrew, kqueue) against a local Node.js 23.11.1 test API on 127.0.0.1, 31 October 2026. The laptop was running other heavy work, so timings show patterns, not benchmarks.