zrk v1.4.2

Hold the rate.
Expose the tail.

zrk is a constant/linear-throughput HTTP(S) load generator — a Zig rewrite of wrk2 that paces in nanoseconds, not milliseconds. It clocks each request from the moment it should have been sent, so a server stall lands in the histogram instead of vanishing from it — and the pacing itself never becomes part of what you're measuring.

zrk — live dashboard · simulated replay

zrk’s live dashboard: elapsed time, offered and achieved rates, transfer, and status counts, then a percentile readout from p50 to max, then a live latency spectrogram — a heat-shaded waterfall of the latency distribution for each one-second interval, newest at the bottom, with a latency scale below it — redrawn every second while a simulated sixty-second ramp runs from 100 to 5,000 requests per second. A 120 millisecond stall at 22 seconds lights up a second, higher band in the waterfall for one interval; past the origin’s capacity near 4,300 requests per second the achieved rate falls behind the schedule and turns red while a second band splits off from the first and climbs higher each second, a queue of backlogged requests the bars this panel replaced would have blended into one stretched-out tail. When the run ends the panel settles in place — the recording dot stops blinking — rather than switching to a separate report.

One static binary — a ~615 KB download, ~1.4 MB unpacked.

Zig 0.16 · HTTP/1.1 · MIT · one fixed request per run

Coordinated omission

The stall you don’t measure is still there.

Most load tools are closed loops: send, wait for the answer, send again. When the server stalls, the tester politely stops sending — the stall produces almost no samples, and the report comes back clean. Gil Tene named the bug coordinated omission; wrk2 was the fix. zrk carries it forward and paces to the nanosecond instead of wrk2's millisecond tick, so every backlogged request lands exactly where it should and gets measured, not smoothed away.

closed loop — send, wait, send againorigin stallsone slow sample — the sends that should have happened never didzrk — constant throughput, measured from intended sendfive backlogged sends, each measured from the time it should have gone out

HdrHistogram

Every request, not five percentiles.

An HdrHistogram buckets latency exponentially, with linear sub-buckets inside each decade — so a 200 µs request and a 200 ms request both land at the same three significant figures of precision. Fixed-width buckets can't do that: fine enough for the baseline, they'd need millions of buckets for the tail; coarse enough for the tail, they'd blur the very stall coordinated omission exists to catch. Every corrected latency zrk records lands here, and the shape survives whole — merge histograms across connections or intervals and get the exact distribution back, not an average of averages.

0.01002003000%90%99%99.9%99.99%percentilemsp50p90the stallp99.9
A real zrk run — 50 connections at 2,000 req/s against a target that stalls on roughly 1 request in 300 — plotted the way the HdrHistogram plotter would: percentile on a log scale, latency linear in ms. The jump past p97 is that stall, captured whole instead of rounded away. Hover the curve for the exact value at any percentile.

Surface

One binary, no scripts.

The whole tool is a single static binary — a histogram, a pacer, an HTTP codec, a dashboard. Everything it can do is a flag; these six are why it exists.

-R 2000 · -R 100:5000

A rate is a promise

zrk offers load; it does not ask permission. The target rate splits evenly across connections and each one paces its own schedule. A:B ramps the rate linearly over the run — sweep it past the knee and you get the latency-vs-load curve, not one blended number.

--latency · --hdr out.hgrm

The whole distribution

Every corrected latency lands in an HdrHistogram — 1 µs to 60 s at three significant figures, with anything past the ceiling clamped into the top bucket rather than dropped. --hdr writes the classic percentile file the HdrHistogram plotter loads directly, format-compatible with wrk2’s --latency output.

--timeseries ramp.ndjson

One row per interval

Each window streams its offered and achieved rate, counts, transfer, and percentiles as NDJSON. Add --timeseries-histogram and every row carries the window’s full distribution — re-percentile a single interval, or merge any subset losslessly.

--slo-p99 250ms · --max-error-rate 1%

A benchmark that can fail the build

SLO gates exit with code 3 on breach and --format json emits one parseable summary. The dashboard falls back to append-only lines when stdout is not a TTY, so piping into CI just works.

--deadline 250ms · --deadline-abort

Bound the tail, not just the wire

--timeout is a wire clock — measured from the actual send, so under overload a connection drifts a whole schedule behind while every attempt still returns fast, and it never bounds CO latency. --deadline works from the scheduled send by shedding before sending: a too-stale request is failed as a deadline error without ever touching the wire, so the backlog drains as errors and no connection is reset — a request that does go out then runs to completion, so recorded latency is bounded by deadline + wire time and --slo-p99 stays a p99 of met latency. --deadline-abort opts into cutting an in-flight request off at the deadline exactly, at the cost of a connection reset per miss that storms a saturated target — off by default. Alongside, a peak-lag gauge (max_schedule_lag_us) flags the moment the client falls behind its own schedule.

-c 200 · -t 4

Threads for parallelism, not per-connection

Every connection runs as a zio coroutine with one request in flight; -t sets how many OS threads the runtime schedules them across (default 2, real parallelism — not cooperative green threads), and -c sets how many connections run. Growing -t buys throughput headroom, not more open connections. Each connection still owns a lock-free histogram and publishes a snapshot per interval.

Reporting

Built to be parsed.

--format json emits one summary object; --timeseries streams a row per interval; --hdr writes the classic .hgrm file the HdrHistogram plotter loads directly. Distributions travel whole — not as five lossy percentiles.

result.json — the summary
{  "target": { "url": "http://origin:8080/", "method": "GET" },  "requests": 19998,  "achieved_rate": 999.80,  "rate_ratio": 0.9998,  "error_rate": 0.000000,  "max_schedule_lag_us": 84,  "latency_us": {    "min": 106, "mean": 422.0, "max": 13991,    "p50": 251, "p75": 337, "p90": 471,    "p99": 1913, "p99_9": 13364, "p99_99": 13988  },  "status_codes": { "2xx": 19998, "5xx": 0 },  "latency_histogram": "HISTFAAAAUJ4nC1P…"}
ramp.ndjson — one row per interval
{"t":1.004,"target_rate":181.7,"achieved_rate":180.9,"requests":181,"errors":0,"bytes":11222,"bytes_per_sec":11177.4,"latency_us":{"p50":244,"p90":651,"p99":1699,"p99_9":2210,"max":2210}}{"t":2.001,"target_rate":263.3,"achieved_rate":262.8,"requests":263,"errors":0,"bytes":16306,"bytes_per_sec":16289.7,"latency_us":{"p50":247,"p90":668,"p99":1745,"p99_9":2388,"max":2388}}{"t":3.006,"target_rate":345.0,"achieved_rate":344.1,"requests":344,"errors":0,"bytes":21328,"bytes_per_sec":21285.4,"latency_us":{"p50":251,"p90":673,"p99":1821,"p99_9":2456,"max":2456}}
  • latency_histogram is the complete distribution as an HdrHistogram V2 compressed blob — the interchange format the Java/Go/JS/Rust libraries read. Store the blob, re-percentile a run after the fact, diff two runs, merge a hundred.
  • achieved_rate and rate_ratio certify that the offered load was actually delivered — if they sag, the numbers describe your client, not your server. max_schedule_lag_us is the companion gauge: the peak the client ever fell behind its own schedule, an early warning that surfaces before the ratio does.
  • The final summary blends a whole ramp into one histogram; the time series is the artifact a ramp exists to produce. Merging every row reproduces the summary exactly, since the intervals partition the run.