A constant/linear throughput HTTP load generator — a Zig 0.16 rewrite of wrk2 that paces every send in nanoseconds instead of wrk2's millisecond-grain scheduler, plus a live in-terminal dashboard of test progress.
Like wrk2, zrk generates load at a fixed request rate and reports latency corrected for coordinated omission via an HdrHistogram, so tail latencies aren't hidden when the server falls behind. Unlike wrk2, that correction isn't riding on a scheduler that rounds every wait up to the next whole millisecond — see Why zrk is more accurate than wrk2 below. zrk also continuously renders the latency percentile spectrum and a p99 sparkline while the test runs.
An open-loop load tester that simply sends "as fast as the server answers" accidentally coordinates with the server: when the server stalls, the tester stops sending, so the stall never shows up in the latency numbers. zrk instead paces each connection to a fixed schedule and measures every request's latency from the time it should have been sent. If the server stalls, backlogged requests accrue latency against their intended send time — the stall is captured, not smoothed away.
Coordinated-omission correction is only as precise as the clock underneath it, and wrk2's clock has a floor.
wrk2's event loop (ae.c, forked from Redis) drives an epoll wait and a timer
wheel that both resolve to whole milliseconds. Its per-request pacing computes
the exact microsecond wait until the next scheduled send
(usec_to_next_send in wrk.c), but then converts that into a timer delay
with:
int msec_to_wait = round((time_usec_to_wait / 1000.0L) + 0.5);That always rounds up to the next whole millisecond — a wait of 1 µs and a wait of 999 µs both become a 1 ms wait. Because wrk2 correctly measures latency from the scheduled send time (that's the coordinated-omission fix), this scheduling overshoot isn't discarded — it lands directly in the recorded latency of every sample that wasn't already overdue. The overshoot is uniform on (0 ms, 1 ms], averaging ~0.5 ms: noise from the tool itself, not the server, and indistinguishable from it in the report. At the sub-millisecond latencies a fast service actually produces, that's not a rounding error — it's frequently larger than the number being measured.
zrk's schedule (src/pace.zig) is a closed-form nanosecond offset per
connection send index, computed directly from the target rate (or ramp),
and each connection sleeps to it via zio's
io_uring runtime (io.sleep(Io.Duration.fromNanoseconds(...))). There is no
tick to round to.
brew install zoxy-io/tap/zrkDownload the latest release binary from the Releases page.
Requires Zig 0.16.
zig build # produces zig-out/bin/zrk
zig build -Doptimize=ReleaseFast
zig build test # run the unit + integration testszrk [options] <url>
-t, --threads <B> Total number of threads to execute load (default 2)
-c, --connections <N> Total connections to keep open (default 10)
-d, --duration <T> Test duration, e.g. 30s, 2m (default 10s)
-R, --rate <N|A:B> Target requests/second (total); A:B ramps
linearly from A to B over the run (default 1000)
-H, --header <K: V> Add a request header (repeatable)
-m, --method <M> HTTP method (default GET)
-b, --body <S|@FILE> Request body; @FILE reads it from a file
(@- = stdin, @@x = a literal "@x")
--timeout <T> Wire timeout per attempt, from the actual
send (default 2s); does not bound CO latency
--deadline <T> Max coordinated-omission latency, from the
scheduled send: a too-stale request is shed
(failed as a `deadline` error, not sent or
recorded) before sending (0 = off)
--deadline-abort Also abort in-flight requests past the deadline.
Resets the connection per miss and churns under
saturation; off by default
--interval <T> Stats window: --timeseries rows and --plain
lines (default 1s)
--refresh <T> Live dashboard redraw rate (default 80ms)
--latency Print full latency spectrum in the final report
-k, --insecure Skip TLS certificate verification
--plain Append-only output instead of a live dashboard
Reporting:
--format <text|json> Final report format (default text)
-o, --output <FILE> Write the final report to FILE (default stdout)
--hdr <FILE> Also write the HdrHistogram percentile
distribution (.hgrm) to FILE
--timeseries <FILE> Stream per-interval NDJSON (throughput +
latency percentiles) to FILE
--timeseries-histogram Add each interval's full latency histogram
(HdrHistogram base64) to every --timeseries row
--no-record-timeouts Drop wire-timed-out requests from the latency
histogram (default: record them, so the tail isn't
truncated). --deadline misses are never recorded.
CI gates (exit code 3 on breach):
--slo-p99 <T> Fail if final p99 latency exceeds T
--max-error-rate <F> Fail if error rate exceeds F (e.g. 0.01 or 1%)
-h, --help Show this help
--version Show version
Durations accept us, ms, s, m, h (a bare number is seconds).
Short options may be attached (-c100) or separated (-c 100).
Ctrl-C (SIGINT) and SIGTERM both stop the run and still report what was
measured, rather than discarding it — useful when a long run has already shown
you what you needed, and the reason docker stop on a containerized run no
longer throws the measurement away.
The report covers the elapsed time, not --duration: duration_s is the real
figure and --format json adds "interrupted": true. CI gates are not
evaluated on an interrupted run, so a partial sample can never report a passing
--slo-p99. A second signal aborts immediately, without a report.
On the live dashboard the stop shows up on the panel itself: the blinking
recording dot beside the clock becomes a ✕ and a line under the counters says
the run is winding down (joining a large fleet takes a moment). Without a live
panel — --plain, a pipe, --format json — the notice goes to stderr instead.
Note for supervisors: the graceful stop has to tear down every connection, which
at high -c takes a moment, so allow some grace before SIGKILL (docker stop
defaults to 10s, which is ample).
| code | meaning |
|---|---|
| 0 | run completed; any configured gates passed |
| 1 | the run failed to start or complete (see the message on stderr) |
| 2 | bad arguments, or a --body file that could not be read |
| 3 | run completed but a --slo-p99 / --max-error-rate gate was breached |
| 130 | interrupted by SIGINT (Ctrl-C); a partial report was still written |
| 143 | interrupted by SIGTERM; a partial report was still written |
# 2000 req/s for 30s over 100 connections
zrk -c100 -d30s -R2000 http://127.0.0.1:8080/
# Ramp linearly from 100 to 5000 req/s over 60s, capturing the latency-vs-load
# curve as a per-interval NDJSON time series (find the knee where latency breaks)
zrk -c200 -d60s -R100:5000 --timeseries ramp.ndjson http://127.0.0.1:8080/
# HTTPS with the full latency spectrum in the final report
zrk -c20 -d1m -R500 --latency https://api.example.com/health
# POST with a body and custom headers
zrk -c10 -R100 -m POST -b '{"ping":1}' \
-H 'Content-Type: application/json' http://127.0.0.1:8080/echo
# POST a body read from a file (@- reads stdin instead)
zrk -c10 -R100 -m POST -b @payload.json \
-H 'Content-Type: application/json' http://127.0.0.1:8080/echo
# CI-friendly, no redrawing dashboard
zrk -c50 -R1000 -d20s --plain http://127.0.0.1:8080/ | tee run.log
# Machine-readable: JSON summary to a file + HdrHistogram .hgrm for plotting
zrk -c50 -R1000 -d20s --format json -o result.json --hdr latency.hgrm \
http://127.0.0.1:8080/
# CI gate: fail the build (exit 3) if p99 regresses past 250ms or errors climb
zrk -c50 -R1000 -d20s --format json -o result.json \
--slo-p99 250ms --max-error-rate 1% http://127.0.0.1:8080/The dashboard automatically falls back to append-only lines when stdout is not a TTY, so piping just works.
For embedding in a benchmark harness or CI, --format json emits a single
summary object (the live dashboard is suppressed) to stdout or --output <file>:
{
"zrk_version": "0.1.0",
"target": { "url": "http://127.0.0.1:8080/", "method": "GET" },
"config": { "connections": 50, "launched": 50, "duration_s": 20.000, "target_rate": 1000, "timeout_ms": 2000, "deadline_ms": 0, "deadline_abort": false, "record_timeouts": true },
"duration_s": 20.002,
"requests": 19998,
"bytes": 1239876,
"achieved_rate": 999.80,
"target_rate": 1000,
"rate_ratio": 0.9998,
"bytes_per_sec": 61987.30,
"error_rate": 0.000000,
"max_schedule_lag_us": 84,
"latency_us": { "min": 106, "mean": 422.0, "stdev": 1222.9, "max": 13991,
"p50": 251, "p75": 337, "p90": 471, "p99": 1913, "p99_9": 13364, "p99_99": 13988 },
"status_codes": { "1xx": 0, "2xx": 19998, "3xx": 0, "4xx": 0, "5xx": 0 },
"errors": { "connect": 0, "read": 0, "write": 0, "timeout": 0, "deadline": 0, "non_2xx_3xx": 0 },
"latency_histogram": "HISTFAAAAUJ4nC1P..."
}latency_histogram is the complete latency distribution encoded as an
HdrHistogram V2 compressed base64 blob — the same interchange format the
Java/Go/JS/Rust HdrHistogram libraries read. It decodes losslessly, so a harness
can store the raw histogram and later re-percentile it, diff two runs, or merge
many runs into one aggregate — none of which the summarized percentiles allow.
zrk can round-trip it too (hdr.decodeBase64), e.g. to merge prior runs.
achieved_rate / rate_ratio tell you whether the client actually sustained
the target load. If rate_ratio is well below 1.0, the client was saturated
(one request in flight per connection — see Little's law below) and the latency
numbers reflect client backpressure, not the server: increase -c.
--hdr <file> additionally writes the classic HdrHistogram percentile
distribution (values in milliseconds), directly loadable by the
HdrHistogram plotter
and format-compatible with wrk2's --latency output.
--timeseries <file> streams one NDJSON object per --interval, each carrying
that window's offered target_rate, achieved_rate, request/error counts,
transfer (bytes, bytes_per_sec), and a delta-histogram latency percentile
set:
{"t":1.006,"target_rate":480.0,"achieved_rate":476.2,"requests":476,"errors":0,"bytes":58852,"bytes_per_sec":58501.0,"latency_us":{"p50":245,"p90":669,"p99":1745,"p99_9":2401,"max":2401}}
This is the artifact a ramp (-R A:B) exists to produce: a curve of latency
against offered load, so you can find the rate at which the server's tail breaks
down. (The final summary's aggregate percentiles blend the whole ramp together,
so they're less useful for a ramp than the time series is.)
Add --timeseries-histogram to append each interval's full latency
distribution as an HdrHistogram V2 base64 blob (latency_histogram) to every
row. Each blob decodes losslessly, so a harness can re-percentile a single
interval or merge any subset of them — e.g. just the windows above a target
rate. Merging every row reproduces the run's summary histogram exactly, since
the intervals partition the run. (Rows get noticeably larger, so it is opt-in.)
Timed-out requests are recorded into the latency histogram by default (as a
coordinated-omission-corrected sample) so the tail isn't silently truncated;
--no-record-timeouts restores wrk2's drop-on-timeout behavior.
These knobs sound similar but measure from different clocks, and the difference only shows up under overload:
--timeoutis a wire timeout, measured from the actual send. It bounds one attempt on the wire (done − actual_send) and catches a hung socket or a dead server. It does not bound coordinated-omission latency: when a connection falls behind its schedule, each attempt still goes out and comes back quickly, so the timeout never trips even though the recorded CO latency (measured from the request's scheduled time) balloons into the tens of seconds. A tight--timeout 1swill happily sit next to a 30 sp50.--deadlineis what actually bounds that tail. It is measured from each request's scheduled send time and targetsdone − scheduled. It is enforced by shedding before sending: a request already staler than the deadline is failed as adeadlineerror without ever touching the wire, which drains the backlog and keeps the client probing near-live latency. This is the usual benchmark intent ("past X ms it's a failed request, per our SLA").
With --deadline set, the latency histogram becomes the distribution of
requests that were sent within the deadline of their schedule, and sustained
overload shows up as a rising deadline error count instead of an unbounded
latency tail. That makes both CI gates meaningful at once under overload:
--slo-p99 stays a p99 of met latency, while --max-error-rate catches the
deadline-miss rate. Deadline misses are never recorded into the histogram
(unlike wire timeouts), so --no-record-timeouts does not affect them.
Shedding bounds the send time, not the completion, so a request that is sent
still runs to completion on the wire: its recorded CO latency is bounded by
deadline + wire time (and wire time by --timeout), not by the deadline
exactly. zrk deliberately does not cut an in-flight request off by default,
because on HTTP/1.1 keep-alive that means resetting the connection — and under
saturation, where most in-flight requests exceed the deadline, resetting every
one storms the target with reconnects and can balloon its memory (an OOM risk
for the system under test). --deadline-abort opts into that in-flight cut-off
(recorded latency then capped at the deadline exactly), at the cost of a
connection reset per miss; use it only against a target you know tolerates the
churn.
max_schedule_lag_us (JSON) / "Peak schedule lag" (text report) is the backlog
gauge: the peak now − scheduled the fleet ever reached. It is populated even
without --deadline, so it is an early warning that the client is falling
behind the offered schedule — a companion to rate_ratio / achieved_rate.
- One request in flight per connection (like wrk/wrk2). Throughput comes
from running many connections; the total
-Rrate is split evenly across them, and each connection paces its own sends to that schedule. - HdrHistogram records every request's corrected latency (1µs–60s, 3
significant figures). Each connection owns its own histogram (lock-free hot
path) and publishes a snapshot once per
--intervalfor the dashboard; the final report aggregates all histograms after the run. Latencies above 60s are clamped into the top bucket rather than dropped, so the request is still counted but the reported tail saturates there. Two histograms per connection at ~136KiB each is the bulk of zrk's memory: budget ~280KiB per-c. - Connections run as coroutines on a zio
io_uring runtime (
std.Io-compatible), one connection per coroutine.
zrk's core is a reusable Zig module, not just a CLI: runner.run drives the
same load-test loop the CLI does (see src/runner.zig) and returns a typed
Report, with an optional progress callback for the periodic snapshots the
dashboard renders. The lower-level pieces it's built from (hdr, cli,
http, connection, stats, report, tls) are exported too.
Add it as a dependency:
zig fetch --save git+https://github.com/zoxy-io/zrk#<commit-or-tag>// build.zig
const zrk_dep = b.dependency("zrk", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("zrk", zrk_dep.module("zrk"));runner.run takes a std.Io instance as a plain parameter, so any conforming
std.Io implementation can be passed in. In practice, zrk dials each
connection with a connect timeout (address.connect(io, .{ .timeout = ... })),
and Zig 0.16's default std.Io.Threaded backend hasn't implemented
connect-with-timeout yet (it's a TODO panic there). zio
does implement it, which is why zio is a hard dependency of the zrk package
itself (see build.zig.zon) and the runtime zrk's own CLI constructs at
startup (src/main.zig). Embedders should do the same — construct a
zio.Runtime and pass its .io() to runner.run — unless you bring another
std.Io implementation that you've verified covers what zrk needs.
const std = @import("std");
const zio = @import("zio");
const zrk = @import("zrk");
var rt = try zio.Runtime.init(allocator, .{});
defer rt.deinit();
const io = rt.io();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
var cfg: zrk.cli.Config = .{
.connections = 50,
.rate = 1000,
.duration_ns = 10 * std.time.ns_per_s,
.interval_ns = 1 * std.time.ns_per_s,
.url = try zrk.cli.parseUrl("http://127.0.0.1:8080/"),
};
const report = try zrk.runner.run(arena.allocator(), io, &cfg, 0, null, null);| File | Responsibility |
|---|---|
src/hdr.zig |
Minimal HdrHistogram (record, percentiles, merge). |
src/cli.zig |
Argument / URL / duration parsing → Config. |
src/http.zig |
Request builder and response parser (Content-Length + chunked). |
src/connection.zig |
The per-connection pacing loop (the coordinated-omission core). |
src/tls.zig |
TLS session setup over a stream (system CA bundle, -k). |
src/stats.zig |
Per-connection state ownership + snapshot/final aggregation. |
src/report.zig |
Machine-readable JSON summary + SLO/CI exit-code gates. |
src/tui.zig |
Live dashboard and final report. |
src/main.zig |
Orchestration: resolve, launch connections, drive the dashboard. |
- HTTP/1.1 only; a single fixed request per run (no scripting).
--timeoutbounds the wire attempt from the actual send (a response that doesn't arrive in time is abandoned and counted asSocket errors: ... timeout N, matching wrk2). It does not bound coordinated-omission latency under overload — use--deadlinefor that (see "--timeoutvs--deadline" above). The connect itself still uses the OS default, since connect-with-timeout is unimplemented in the std backend and panics.-kskips certificate verification; with the std TLS client this also omits SNI, so name-based virtual hosts may respond differently under-k.