Skip to main content

wrk Load Testing: A Benchmark Tutorial With Real Output

wrk load testing tutorial: install wrk, read its output, send POSTs with Lua, and avoid the 2-second timeout that hides slow responses. Tested on wrk 4.2.0.

wrk Load Testing: A Benchmark Tutorial With Real Output

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 -c sets 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 --timeout above 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 a done() function.
  • POST bodies, methods and most custom requests need a Lua script. A Content-Type header 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.

SystemCommandVersion packaged (October 2026)
macOSbrew install wrk4.2.0
Debian, Ubuntusudo apt install wrk4.1.0
Alpineapk add wrk4.2.0
Windowsthe Ubuntu command inside WSL4.1.0
Anything elsegit clone the repository and run make4.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). +/- Stdev is 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 ranResult
The POST script without the Content-Type line123,849 of 123,849 responses were HTTP 415
-H "Content-Type: application/json" with no scriptwrk 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 flagLatency wrk reportedTimeoutsExit code
3-second response, default --timeout0.00 µs (all percentiles)120
3-second response, --timeout 5s3.00 to 3.01 s (all percentiles)00
No response ever0 requests, no error line00

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:

-cRequests/secAverage latency99th percentile
137.9626.32 ms29.79 ms
274.4826.81 ms33.83 ms
4150.3626.58 ms30.29 ms
8152.0752.47 ms62.94 ms
16151.99104.64 ms112.42 ms
32149.40210.96 ms222.04 ms
64148.99418.01 ms519.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--timeout99th percentileMaxTimeouts
nonedefault211 ms, 225 ms321 ms, 363 ms0
1 seconddefault833 ms, 824 ms1.00 s, 0.995 s0
3 secondsdefault204 ms, 215 ms284 ms, 374 ms10, 10
3 seconds10 s2.85 s, 2.83 s3.03 s, 3.00 s0

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 ranWhat wrk printedExit code
A port with nothing listeningunable to connect to 127.0.0.1:18099 Connection refused1
A URL without http://invalid URL: and the usage text1
-c lower than -tnumber of connections must be >= threads1
A URL with no pathrequests /, which on our API returned 404 to every request0

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.c and src/wrk.lua at 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.

Bogdan

Founder at LoadFocus

Bogdan builds and runs the tools this blog is about. He writes from what the products actually do in production, including the parts that break.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.