You shipped an endpoint. It works in your browser. But you have no idea what happens when 400 people hit it at once. Does latency stay flat, or does the ninety-ninth percentile blow up? Can the box hold 1,000 requests per second, or does it fall over at 300?
wrk answers that. It’s a small command-line tool that fires a lot of HTTP traffic at a URL and reports how fast the server responded under that load.
What wrk Is and When to Use It
wrk is a modern HTTP benchmarking tool. It generates load from a single multi-core machine and measures the latency and request rate the server returns. It uses multithreading plus a scalable event loop (epoll on Linux, kqueue on macOS), so one instance can push a lot of traffic without needing a fleet of load boxes.
Reach for wrk when you want raw performance numbers:
- How many requests per second can this endpoint sustain?
- What does latency look like at the median versus the tail?
- Does the server hold up over a sustained window, or does it degrade after a minute?
wrk is a benchmarking tool, not a test suite. It measures speed. It does not check that the JSON body is right, that the status code is 200, or that the API contract held. Keep that distinction in mind. We’ll come back to it near the end, because it changes how you fit wrk into a real testing workflow. If you want the broader picture first, this guide to API load testing covers the concepts wrk puts into practice.
Installing wrk
macOS
Homebrew has a prebuilt binary, which is the path of least resistance:
brew install wrk
On Apple Silicon this matters. Building from source can hit LuaJIT ARM64 issues, so the Homebrew binary saves you the headache.
Linux (build from source)
There’s no official apt package, so you build it. Install the toolchain and OpenSSL headers first:
sudo apt-get install build-essential libssl-dev git -y
Then clone and compile:
git clone https://github.com/wg/wrk.git
cd wrk
make
That produces a wrk binary in the current directory. Move it onto your PATH so you can call it from anywhere:
sudo cp wrk /usr/local/bin
Confirm it runs:
wrk --version
The Basic Command
Here’s the shape of every wrk run:
wrk -t12 -c400 -d30s http://127.0.0.1:8080/index.html
Four things are happening. Let’s break down the flags.
-t, --threadssets the number of OS threads wrk spins up. A good starting point is one thread per CPU core. Here it’s 12.-c, --connectionssets the total HTTP connections held open across all threads. Here 400 connections are spread across the 12 threads. Connections are how you simulate concurrent clients.-d, --durationsets how long the test runs. Accepts values like30s,2m, or2h. Here it’s 30 seconds.
Two more flags you’ll use constantly:
--latencyprints a detailed latency percentile breakdown. Turn this on almost every time. Averages hide tail latency, and the tail is usually what hurts users.--timeoutrecords a request as timed out if no response arrives within the window you set, for example--timeout 2s. Without it, slow responses can skew your latency numbers.
A run you’ll reach for often looks like this:
wrk -t8 -c200 -d30s --latency http://localhost:3000/api/users
Eight threads, 200 connections, 30 seconds, with the full latency distribution printed at the end.
Reading the Output
wrk prints a compact report. Here’s a real run against a small service:
Running 5s test @ http://10.135.232.163:3000
2 threads and 5 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 3.82ms 2.64ms 26.68ms 85.81%
Req/Sec 550.90 202.40 0.98k 68.00%
5494 requests in 5.01s, 1.05MB read
Requests/sec: 1096.54
Transfer/sec: 215.24KB
Read it from the bottom up, because the last two lines are the headline.
Requests/sec is throughput: how many requests the server completed per second on average. Here it’s 1,096. This is the number you compare across runs and across code changes.
Transfer/sec is bandwidth: how much data moved per second. Useful when payloads are large or you suspect you’re bandwidth-bound rather than CPU-bound.
Now the Thread Stats table, which describes distribution, not just the average:
- Latency row: average latency was 3.82ms, but the standard deviation was 2.64ms and the max spiked to 26.68ms. A high max relative to the average is a signal. Some requests are slow even if most are fast.
- Req/Sec row: the per-thread request rate, again with its spread.
The +/- Stdev column tells you what percentage of samples fell within one standard deviation. Lower percentages mean a wider, less predictable spread.
The line 5494 requests in 5.01s confirms the total volume the run actually pushed.
When you add --latency, wrk prints a percentile block so you can see the tail directly:
Latency Distribution
50% 3.21ms
75% 4.86ms
90% 7.09ms
99% 14.13ms
The 99th percentile is the number to watch. If 99% of requests finish in 14ms but your average is 3.82ms, one in a hundred users is waiting far longer than the average suggests. Averages lie about tails. Percentiles don’t.
Sending POST Requests and Custom Headers with a Lua Script
By default wrk sends GET requests. To send a POST, add a body, or set custom headers, you pass a Lua script with -s.
Create a file called post.lua:
wrk.method = "POST"
wrk.body = '{"name": "Ada", "role": "engineer"}'
wrk.headers["Content-Type"] = "application/json"
Three fields do the work. wrk.method sets the HTTP verb. wrk.body sets the request body. wrk.headers is a table where each key is a header name.
Run it by pointing -s at the script:
wrk -t4 -c100 -d30s -s post.lua --latency http://localhost:3000/api/users
For a form-encoded POST instead of JSON, the wrk repo ships this exact example:
wrk.method = "POST"
wrk.body = "foo=bar&baz=quux"
wrk.headers["Content-Type"] = "application/x-www-form-urlencoded"
You can also set headers with the -H flag for simpler cases, without a script:
wrk -t4 -c100 -d30s -H "Authorization: Bearer TOKEN123" --latency http://localhost:3000/api/protected
Use -H for a header or two. Use a Lua script when you need a body, a non-GET method, or per-request logic.
The Limits: wrk Doesn’t Check Correctness
Here’s the part people miss. wrk tells you how fast the server answered. It does not tell you whether the answer was right.
Point wrk at an endpoint that returns HTTP 500 on every request, and you’ll get a clean-looking report with a high requests-per-second number. wrk counts a completed HTTP exchange. It does not assert on the status code, validate the response body against a schema, or confirm the API did what it was supposed to do. Errors can even look fast, because a server rejecting requests early does less work per request.
So wrk answers “is it fast enough under load?” It cannot answer “is it correct?” Both questions matter, and they need different tools. A load number on a broken endpoint is a number you shouldn’t trust. This is exactly why teams pair a benchmarking tool with a functional test suite. One proves speed. The other proves behavior.
Where Apidog and Functional Testing Fit
The clean workflow is two layers, run in order.
First, validate behavior. Before you care how fast an endpoint is, confirm it’s correct. In Apidog you build test scenarios that send real requests and assert on what comes back: status codes, JSON fields, response schema, and business logic. You can chain requests, pass data between steps, and run the same scenario across environments. This is the layer that catches the broken 500 that wrk happily benchmarks.
Then, benchmark throughput. Once behavior is verified, run wrk against the same endpoints to see how they hold up under concurrency and sustained load. Apidog also has built-in performance testing if you’d rather keep functional and load work in one place, but wrk is a fine dedicated tool for raw command-line benchmarking.
The functional layer runs in CI, not just on your laptop. The Apidog CLI is headless, so it drops into any pipeline step that can run Node. Install it:
npm install -g apidog-cli
Then run a saved test scenario or suite by ID:
apidog run \
--access-token "$APIDOG_ACCESS_TOKEN" \
-t <scenarioOrSuiteId> \
-e <environmentId> \
-r cli,html,junit
-t is the scenario, folder, or suite ID to run. -e is the environment ID. -r picks the report formats, one or more of cli, html, json, and junit. The JUnit output plugs straight into most CI systems for pass/fail gating. For data-driven runs, add -d (or --iteration-data) with a file path or test-data ID to iterate the same scenario over many input rows.
The CLI runs saved Apidog scenarios and suites. It is headless, not an interactive request sender, and it is not a load generator. It’s the correctness gate. wrk is the speed gauge. Run the correctness gate in your pipeline (see this CLI CI/CD walkthrough or the GitHub Actions guide for copy-paste config), then benchmark with wrk when you need the throughput numbers. The full CLI reference covers the rest of the flags.
FAQ
What’s the difference between wrk and ab (ApacheBench)? Both fire HTTP load and report requests per second. wrk is multithreaded and uses an event loop, so it generates more load from one machine and handles high concurrency better. ab is single-threaded. For heavy load from a single box, wrk usually scales further. Neither one checks response correctness.
How many threads and connections should I use? Start with one thread per CPU core, and set connections to the concurrency level you want to simulate. If you have 8 cores and want to model 200 concurrent clients, try -t8 -c200. Watch the client machine. If wrk itself is CPU-bound, your numbers reflect the load generator’s limit, not the server’s. Ramp connections up until throughput stops rising.
Can wrk test HTTPS endpoints? Yes. Point it at an https:// URL and wrk handles TLS. That’s why the Linux build needs libssl-dev. TLS handshakes add CPU cost on both ends, so expect lower raw throughput against HTTPS than plain HTTP.
Does wrk validate the response body or status code? No. wrk counts completed HTTP exchanges and measures timing. It does not assert on status codes or bodies, so an endpoint returning errors can still post a high requests-per-second number. Use a functional test suite, such as one run through the Apidog CLI, to verify correctness, then use wrk for throughput.
How long should a load test run? Long enough to get past warm-up effects like cold caches and JIT compilation. A few seconds is fine for a quick check, but 30 seconds to a few minutes gives more stable numbers and surfaces degradation that only shows up under sustained load. Use -d30s as a sane default and lengthen it when you’re chasing slow leaks.



