This guide is the architectural and operational reference for wrk, the multithreaded HTTP benchmarking tool. It is written for the people who deploy wrk in test infrastructure, build it for distribution, integrate it into pipelines, and read or extend its source. Everything here is derived directly from the source at commit a211dd5.
For installation and day-to-day usage, see the companion User Guide. This document concentrates on how wrk is built, how its internals fit together, what its security posture is, and how to operate it reliably.
1. Executive Summary
wrk is a single-process, multithreaded HTTP/1.x load generator written in C99 with an embedded LuaJIT scripting engine. It is designed to saturate a target server from one multi-core machine by pairing a small pool of OS threads with a high-performance, OS-native event loop (epoll, kqueue, event ports, or select).
| Attribute | Value |
|---|---|
| Language | C (C99) plus LuaJIT for scripting |
| Concurrency model | N OS threads, each running an independent event loop and Lua state |
| I/O model | Non-blocking sockets driven by an event-notification API |
| Protocol | HTTP/1.x over plain TCP or TLS (OpenSSL) |
| Configuration surface | Command-line flags + optional Lua script only — no config files, no environment variables |
| External dependencies | LuaJIT, OpenSSL (both bundled and built statically by default) |
| Platforms | Linux, macOS, FreeBSD, illumos/Solaris (UNIX-like) |
| Build system | GNU make |
| Distinguishing feature | Coordinated-omission correction for honest tail-latency numbers |
The design philosophy is minimalism: a small, auditable core that does one thing — generate load and measure latency accurately — and delegates customization to Lua rather than accreting flags. A large fraction of the C source is well-known vendored code (the Redis event loop, the Node.js HTTP parser, Redis's zmalloc), leaving the project's own logic compact and focused.
2. Architecture
2.1 High-level structure
+--------------------------------------+
| main() / wrk.c |
| parse_args -> resolve -> spawn |
| threads -> sleep(duration) -> join |
| -> aggregate -> print -> done() |
+-------------------+------------------+
|
pthread_create per thread | shared config (cfg), statistics
+------------------------------+------------------------------+
| | |
+-----v------+ +-----v------+ +-----v------+
| thread 0 | | thread 1 | ... | thread N |
| ae loop | | ae loop | | ae loop |
| Lua state | | Lua state | | Lua state |
| C conns/T | | C conns/T | | C conns/T |
+-----+------+ +-----+------+ +-----+------+
| | |
non-blocking sockets non-blocking sockets non-blocking sockets
(net.c / ssl.c) via sock function table TCP_NODELAY, keep-alive
| | |
+------------------------------+------------------------------+
|
+---------v---------+
| target server |
+-------------------+
The main thread parses arguments, resolves the target, creates one worker thread per -t, sleeps for the test duration, then signals every thread to stop, joins them, aggregates their counters, and prints the report.
2.2 Threading and connection model
main()createscfg.threadsworker threads withpthread_create, each runningthread_main.- Each thread owns its own
aeEventLoop, its own LuaJITlua_State, andconnections / threadsconnectionstructures. - The event loop is sized at
10 + connections * 3file-descriptor slots per thread to leave headroom for reconnection churn. - Threads are entirely independent during the run — there is no cross-thread shared mutable request state — which is why a script's globals are per-thread.
- The duration is enforced by the main thread calling
sleep(cfg.duration)and then setting a globalstopflag; each thread's periodic timer observes the flag and callsaeStopon its loop.
2.3 Per-thread event loop
Each connection is a non-blocking socket registered with the thread's event loop. The lifecycle is a state machine driven by readability/writability callbacks in wrk.c:
| Callback | Trigger | Responsibility |
|---|---|---|
connect_socket |
thread start / reconnect | Create the socket, set O_NONBLOCK and TCP_NODELAY, begin async connect |
socket_connected |
socket becomes writable after connect | Finish TLS handshake (if any), init the HTTP parser, register read+write handlers |
socket_writeable |
socket writable | Optionally apply scripted delay(), (re)build the request for dynamic scripts, write it, track partial writes |
socket_readable |
socket readable | Read into an 8 KB buffer (RECVBUF), feed bytes to the HTTP parser, count bytes |
response_complete |
parser finishes a response | Record latency, count status errors, invoke response() script hook, decide keep-alive vs reconnect |
record_rate |
every 100 ms timer | Sample the thread's request rate into the requests histogram; honor the stop flag |
Note
When the server sends
Connection: close(or HTTP keep-alive is otherwise not indicated),wrktears down and reconnects the socket (reconnect_socket) rather than reusing it. Persistent connections are far more efficient; design your test target to support keep-alive for realistic throughput.
2.4 The socket abstraction (plain vs TLS)
net.c and ssl.c implement the same five-function interface, dispatched through a struct sock function table (connect, close, read, write, readable). At startup main() inspects the URL scheme: for https it swaps the plain sock_* functions for the ssl_* versions. The rest of the engine is oblivious to which transport is in use — a clean strategy-pattern boundary.
Both transports return a three-valued status (OK, ERROR, RETRY); RETRY lets the non-blocking loop re-arm on EAGAIN / SSL_ERROR_WANT_* without treating a would-block as a failure.
2.5 Statistics engine
stats.c implements a fixed-bucket histogram (stats struct: a flat uint64_t data[] array indexed by value). This gives O(1) recording and bounded memory.
- Latency is recorded in microseconds, bucketed up to
timeout * 1000. - Request rate is recorded into a histogram sized to
MAX_THREAD_RATE_S(10,000,000). - Recording uses lock-free atomics (
__sync_fetch_and_add, compare-and-swap on min/max), so threads update shared histograms without a mutex. - Derived metrics —
stats_mean,stats_stdev,stats_percentile,stats_within_stdev— are computed once at the end.
Coordinated-omission correction
After the run, stats_correct adjusts the latency histogram against the expected per-connection request interval. If the server stalls, a naive load generator stops issuing requests and therefore never records the latency those would-be requests experienced — hiding the true tail. wrk back-fills synthetic samples so a long pause is reflected across the percentiles. This is the single most important correctness feature of wrk's measurement and the reason its p99 numbers are trustworthy.
2.6 Unit parsing and formatting
units.c provides the SI (1k/1M/1G) and time (s/m/h) parsing used for arguments (scan_metric, scan_time) and the human-friendly formatting of the report (format_metric, format_binary, format_time_us). Binary sizes use 1024 scaling; metric counts use 1000 scaling.
2.7 Component inventory
| File | Origin | Role |
|---|---|---|
wrk.c |
project | Entry point, argument parsing, thread orchestration, socket state machine, reporting |
wrk.h |
project | Core types: thread, connection, buffer; tunable constants |
script.c / script.h |
project | LuaJIT bridge: Lua state setup, hook invocation, wrk/thread/stats/addr metatables |
wrk.lua |
project | Lua-side glue: the wrk table, wrk.format, wrk.resolve, wrk.init, wrk.setup |
net.c / net.h |
project | Plain-TCP socket operations and the sock/status interface |
ssl.c / ssl.h |
project | OpenSSL transport implementing the same socket interface |
stats.c / stats.h |
project | Histogram statistics, percentiles, coordinated-omission correction |
units.c / units.h |
project | SI/time argument parsing and report formatting |
aprintf.c / aprintf.h |
project | Allocating printf helper used by formatting |
ae.c, ae_epoll.c, ae_kqueue.c, ae_evport.c, ae_select.c, ae.h |
Redis (Salvatore Sanfilippo) | The event loop and its per-OS backends |
http_parser.c / http_parser.h |
Joyent / Node.js | The HTTP/1.x response parser |
zmalloc.c / zmalloc.h, atomicvar.h |
Redis | Allocation wrappers and atomic-counter macros |
config.h |
project | Compile-time selection of the event backend per OS |
Important
Roughly two-thirds of the C source is vendored, audited code from Redis and Node.js. When reasoning about
wrk's behavior or attributing licenses, consult theNOTICEfile. The project's own surface is small:wrk.c,script.c,net.c,ssl.c,stats.c,units.c,aprintf.c, andwrk.lua.
3. The LuaJIT integration
The C–Lua boundary lives in script.c. Understanding it matters for anyone writing non-trivial scripts or debugging script behavior.
3.1 State creation
script_create builds a fresh lua_State per thread: it opens the standard libraries, loads the embedded wrk module (require "wrk"), registers three metatables (wrk.addr, wrk.stats, wrk.thread), parses the URL, and populates wrk.scheme/host/port/path plus the headers table from the -H flags. The wrk.lua source is compiled to bytecode at build time and linked into the binary, so no .lua file needs to ship alongside wrk.
3.2 Static vs dynamic requests
At startup the first thread's state is interrogated to set engine-wide behavior flags:
| Flag | Set by | Effect |
|---|---|---|
cfg.pipeline |
script_verify_request |
Number of HTTP requests found in the generated request buffer (enables pipelining) |
cfg.dynamic |
!script_is_static |
Whether request() is a function (rebuild every cycle) vs a static buffer (build once) |
cfg.delay |
script_has_delay |
Whether a delay() hook exists |
| response parsing | script_want_response |
Header/body parser callbacks are only wired up if a response() hook exists |
This lazy wiring is a deliberate performance optimization: if your script does not define response(), wrk never pays to parse headers and bodies.
3.3 Request verification
Before load begins, script_verify_request parses the first generated request through the HTTP parser. A malformed request causes wrk to print <message> at <line>:<column> (or incomplete request) and exit(1). This fail-fast check prevents a whole run from silently producing garbage.
3.4 Cross-thread data transfer
script_copy_value deep-copies values between lua_States for thread:get/thread:set. Only boolean, nil, number, string, and tables of those types are transferable; anything else raises a Lua error. This is the only sanctioned channel for moving data between the setup context and a running thread.
4. Building & Packaging
4.1 The default build
make
The default target compiles the project sources plus bundled LuaJIT and OpenSSL into a single statically linked wrk binary. Key build settings from the Makefile:
| Setting | Value |
|---|---|
CFLAGS |
-std=c99 -Wall -O2 -D_REENTRANT (plus per-OS additions) |
LIBS |
-lluajit-5.1 -lm -lssl -lcrypto -lpthread (plus -ldl on Linux) |
| Object dir | obj/ |
| Version string | git describe --tags --always --dirty, compiled into obj/version.o |
| Lua bytecode | src/wrk.lua compiled via luajit -b into obj/bytecode.c |
Per-OS adjustments are applied automatically based on uname -s:
OS (TARGET) |
Extra flags |
|---|---|
linux |
-D_POSIX_C_SOURCE=200112L -D_BSD_SOURCE -D_DEFAULT_SOURCE, -ldl, -Wl,-E |
darwin |
sets MACOSX_DEPLOYMENT_TARGET |
freebsd |
-D_DECLARE_C99_LDBL_MATH, -Wl,-E |
sunos |
-D_PTHREADS -D_POSIX_C_SOURCE=200112L, -lsocket |
4.2 Building against system libraries
For distribution packaging, link the system LuaJIT and OpenSSL instead of the bundled copies:
make WITH_LUAJIT=/usr WITH_OPENSSL=/usr
Each variable adds the corresponding include and lib directories to the compile and link flags and skips building that bundled dependency.
Caution
System OpenSSL must be 1.1.0 or newer (per the changelog). Linking an older OpenSSL will fail to build or misbehave at runtime.
4.3 The compile-time event backend
config.h selects the event-notification backend at compile time from the platform macros — kqueue on FreeBSD/macOS, epoll on Linux, event ports on illumos/Solaris — falling back to select. There is no runtime switch; the backend is baked into the binary and reported by wrk --version.
4.4 Deployment checklist
- [ ] Build on, or for, the same OS/architecture as the load-generating host.
- [ ] Confirm OpenSSL ≥ 1.1.0 if using
WITH_OPENSSL. - [ ] Run
wrk --versionand confirm the expected event backend (epoll/kqueue/etc.). - [ ] Smoke-test against a known endpoint before a real campaign.
- [ ] Copy the single
wrkbinary to a directory onPATH.
5. Security Analysis
wrk is a client-side load generator; its threat surface is small but has a few sharp edges that operators must understand.
5.1 TLS certificate verification is disabled
In ssl_init, the TLS context is created with:
SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);
SSL_CTX_set_verify_depth(ctx, 0);
wrk performs no certificate validation. It will happily connect to hosts with expired, self-signed, mismatched, or otherwise untrusted certificates.
Warning
Because certificate verification is off and cannot be enabled by any flag,
wrkmust never be used to assert that a server's TLS is correctly configured, and it is unsuitable as a connectivity/security probe. It is a benchmarking tool only. Treat any HTTPS benchmark as transport-encrypted but unauthenticated.
The context does send SNI (SSL_set_tlsext_host_name) and uses SSLv23_client_method (negotiate the best available protocol), with client-side session caching enabled.
5.2 Lua scripts execute with full privileges
A -s script runs with the standard Lua libraries opened (luaL_openlibs), including io and os. A script can read and write files and run commands with the privileges of the user running wrk.
Caution
Treat
-sscripts as trusted code. Never run awrkscript from an untrusted source, and never runwrkasrootwith a third-party script. There is no sandbox.
5.3 No authentication handling beyond headers
wrk has no notion of credentials, cookies, or auth flows. Anything sensitive (bearer tokens, API keys) is passed via -H headers or set in a script, and will appear in process arguments / script files. Protect those as you would any secret on the host.
5.4 Denial-of-service potential
wrk is, by design, a tool for generating overwhelming load. Pointed at a third party it is an effective DoS instrument.
Important
Only benchmark systems you own or are explicitly authorized to test. High connection/thread counts against an unprepared target can exhaust its sockets, file descriptors, and CPU.
5.5 Input handling
wrk parses only the URL and the responses it receives. Response parsing uses the hardened, widely deployed Node.js HTTP parser. Certificate verification aside, there is no parsing of untrusted local input beyond the operator-supplied script.
6. Operations
6.1 Running a campaign
There are no services, daemons, or background state. Each invocation is a self-contained run that prints a report and exits. Operational control is therefore entirely about invocation parameters and host tuning.
wrk -t12 -c400 -d2m --latency --timeout 5s https://target.internal/health
6.2 Host tuning for high load
The load-generating host, not wrk, is usually the bottleneck. Tune it:
| Concern | Tuning |
|---|---|
| Ephemeral ports | Widen the local port range (net.ipv4.ip_local_port_range) so high -c does not exhaust ports |
| Socket reuse | Ensure closed sockets recycle quickly (net.ipv4.tcp_tw_reuse where appropriate) |
| File descriptors | Raise ulimit -n above the connection count plus headroom |
| Threads | Keep -t near the core count; oversubscription adds scheduling overhead |
On the target side, raise the listen(2) backlog above the concurrent connection count so the initial connection burst is absorbed rather than refused.
6.3 Stopping a run
Send SIGINT (Ctrl+C). wrk installs a handler that sets the stop flag; threads finish their current cycle, the loops stop, and the partial report prints. SIGPIPE is ignored so a peer closing mid-write does not kill the process.
6.4 Interpreting results operationally
- Compare
Requests/secacross runs, not absolute numbers across tools — methodology differs. - Watch the error lines.
Non-2xx or 3xx responsesandSocket errorsinvalidate a throughput number if high; the server may be shedding load. - Trust p99 over the average. Use
--latencyand read the 99th percentile for tail behavior; thanks to coordinated-omission correction these numbers reflect real stalls. - Re-run for stability. A low
+/- Stdevindicates consistent timing; a high one means the result is noisy and should be repeated.
6.5 Integrating into CI/automation
Because output is fixed-format text, parse it with a done() script that emits machine-readable output instead of scraping stdout:
done = function(summary, latency, requests)
io.write(string.format("p99_us=%d rps=%.0f errors=%d\n",
latency:percentile(99),
summary.requests / (summary.duration / 1e6),
summary.errors.status + summary.errors.timeout))
end
Tip
wrkhas no non-zero exit code for "benchmark failed" conditions like high error rates — it exits0after a normal run. If you gate a pipeline on results, compute pass/fail in yourdone()script and emit a sentinel your harness can check.
7. Tunable Constants & Internals Reference
Compile-time constants from wrk.h that govern runtime behavior:
| Constant | Value | Meaning |
|---|---|---|
RECVBUF |
8192 |
Per-read socket buffer size (bytes) |
SOCKET_TIMEOUT_MS |
2000 |
Default request timeout (overridable with --timeout) |
RECORD_INTERVAL_MS |
100 |
How often each thread samples its request rate |
MAX_THREAD_RATE_S |
10000000 |
Upper bound of the request-rate histogram |
Default configuration applied in parse_args (before flags):
| Field | Default |
|---|---|
threads |
2 |
connections |
10 |
duration |
10 (seconds) |
timeout |
SOCKET_TIMEOUT_MS (2000 ms) |
Validation rules enforced by parse_args:
- A URL argument is required, with a parseable scheme and host.
threadsanddurationmust be non-zero.connectionsmust be non-zero and>= threads.
Negative knowledge (what wrk does NOT have)
These are common assumptions that are false for wrk. Documented to prevent fabrication.
- No configuration file of any kind, and no environment variables are read. Configuration is CLI + script only.
- No
-roption. The characterrappears in thegetoptoption string but has no handler; passing-rfalls through to the usage error. It is vestigial and non-functional. - No built-in request-rate limiting flag. Throttling is only possible via a
delay()script hook. - No JSON/CSV output flag. Machine-readable output requires a
done()script. - No HTTP/2 or HTTP/3. The bundled parser handles HTTP/1.x only.
- No cookie jar or redirect following. Responses are measured, not navigated.
- No certificate verification, and no flag to enable it.
- No distributed/cluster mode. One process, one host. Coordinate multiple hosts externally if you need more load than one machine can produce.
- No Windows support.
- No
done()-driven exit codes. A completed run exits0regardless of error counts.
8. Potential Improvements
Observations for maintainers and contributors, grounded in the current source:
- Optional certificate verification. A flag to enable
SSL_VERIFY_PEER(with a CA path) would letwrkdouble as a realistic HTTPS client without weakening the default benchmarking behavior. - Configurable percentile set. The
--latencydistribution is hard-coded to 50/75/90/99 (print_stats_latency). Exposing the percentiles (or always including 99.9/99.99) would aid tail analysis without a customdone()script. - A machine-readable output mode. A built-in
--jsonwould remove the need for a boilerplatedone()script in CI and standardize parsing. - Non-zero exit on error thresholds. An optional flag to exit non-zero when
Non-2xx/3xxor socket errors exceed a bound would make pipeline gating ergonomic. - Connection distribution remainder.
connections / threadsinteger division silently drops the remainder; documenting or redistributing it would avoid surprising users who pass non-multiple counts. - Remove the vestigial
-rtoken from thegetoptstring to avoid confusion.
Note
wrk's value lies in its small, auditable core and accurate measurement. Improvements should preserve that minimalism — most customization belongs in Lua, not new flags.
9. Quick Reference
Command-line options
| Short | Long | Arg | Default |
|---|---|---|---|
-c |
--connections |
N | 10 |
-d |
--duration |
T | 10s |
-t |
--threads |
N | 2 |
-s |
--script |
file | — |
-H |
--header |
header | — |
--latency |
— | off | |
--timeout |
T | 2s | |
-v |
--version |
— | — |
-h |
--help |
— | — |
Lua hooks
| Hook | Signature | Phase |
|---|---|---|
setup |
setup(thread) |
setup |
init |
init(args) |
running |
delay |
delay() -> ms |
running |
request |
request() -> string |
running |
response |
response(status, headers, body) |
running |
done |
done(summary, latency, requests) |
done |
wrk table & helpers
| Member | Purpose |
|---|---|
wrk.scheme/host/port/method/path/headers/body |
Request template fields |
wrk.format(method, path, headers, body) |
Build a request string |
wrk.lookup(host, service) |
Resolve to a table of addresses |
wrk.connect(addr) |
Test connectivity to an address |
thread.addr / thread:get/set/stop |
Per-thread control |
Stats object
| Accessor | Returns |
|---|---|
.min / .max / .mean / .stdev |
Summary values |
:percentile(p) |
Value at percentile p |
(i) |
Raw value and count at index i |
#stats |
Number of distinct recorded values |
Source tree
wrk/
├── Makefile # build, dependency bundling, per-OS flags
├── INSTALL # build instructions
├── SCRIPTING # Lua API reference
├── CHANGES # changelog
├── NOTICE # vendored-code attributions & licenses
├── scripts/ # example Lua scripts
│ ├── addr.lua # per-thread address selection
│ ├── auth.lua # capture token from a response
│ ├── counter.lua # dynamic path/header per request
│ ├── delay.lua # random per-request delay
│ ├── pipeline.lua # HTTP pipelining
│ ├── post.lua # POST with body
│ ├── report.lua # custom CSV report via done()
│ ├── setup.lua # cross-thread data via setup()
│ └── stop.lua # thread:stop() after N responses
└── src/
├── wrk.c/.h # entry point, orchestration, socket state machine
├── wrk.lua # Lua-side glue (wrk table, format, resolve)
├── script.c/.h # LuaJIT bridge
├── net.c/.h # plain TCP transport
├── ssl.c/.h # OpenSSL transport
├── stats.c/.h # histogram + coordinated-omission correction
├── units.c/.h # SI/time parsing & formatting
├── aprintf.c/.h # allocating sprintf
├── ae*.c, ae.h # Redis event loop (epoll/kqueue/evport/select)
├── http_parser.* # Node.js HTTP/1.x parser
├── zmalloc.* # Redis allocation wrappers
├── atomicvar.h # atomic-counter macros
└── config.h # compile-time backend selection