An HTTP load generator that does not lie about tail latency.
Most load generators quietly under-report p99. hammer measures latency from when each request should have departed, not from when it actually got sent, so the requests that queued up behind a server stall still appear in the histogram. Both measurements ship, so you can see the difference yourself.
No libcurl, no boost, no HTTP library. The sockets, the protocol parsing and the statistics are all in this repo.
npm install -g hammer-load
hammer --version
That pulls a prebuilt binary for Linux x86_64, macOS (Intel and Apple Silicon) or Windows x86_64 and verifies its SHA-256. If there is no prebuilt binary for your platform it falls back to building from source, which needs CMake 3.21+ and a C++20 compiler.
You can also grab a binary straight from releases, or build it yourself:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release
hammer [options] <url>
-c, --connections N total connections (default 50)
-t, --threads N worker threads (default 4)
-d, --duration S test duration in seconds (default 10)
-r, --rate N target req/s; turns on open-loop mode
--closed-loop measure from actual send even with --rate set
-m, --method M HTTP method (default GET)
-H, --header "K: V" extra header, repeatable
--body FILE request body from file
--timeout MS per-request timeout (default 2000)
--latency print the full percentile spectrum
--json machine-readable output
--version print version and exit
-h, --help
hammer -c 50 -t 4 -d 30 --rate 20000 --latency http://localhost:8080/
Bad input exits 1 with a message naming the flag. --connections below --threads is an error,
not a silent clamp.
A closed-loop generator sends a request, waits for the response, then sends the next one. When the server stalls for 200ms, the generator sends nothing during the stall. The requests that would have hit that stall are never issued, never timed, and never appear in the histogram. It omits exactly the samples that mattered. Gil Tene named this coordinated omission.
The fix is to decide when each request should depart before the test starts:
intended[i] = t0 + i * (1e9 / rate)
latency[i] = response_arrival - intended[i] // corrected
latency[i] = response_arrival - actual_send // what most tools report
--rate turns on the schedule. --closed-loop keeps the same schedule and the same requests, and
only changes where the stopwatch starts — so the comparison below is the same load measured two
ways, not two different loads.
tools/pausing_server.py serves a small response quickly and stalls for 200ms every 3000
responses, the way a stop-the-world GC pause behaves. Both runs issued 20,000 requests at
1999 req/s:
python tools/pausing_server.py --port 8082 --pause-ms 200 --every 3000
hammer -c 20 -t 4 -d 10 --rate 2000 --latency http://127.0.0.1:8082/
hammer -c 20 -t 4 -d 10 --rate 2000 --latency --closed-loop http://127.0.0.1:8082/
| percentile | corrected | closed loop | ratio |
|---|---|---|---|
| 50% | 255.62us | 183.55us | 1.4x |
| 75% | 1.70ms | 251.78us | 6.8x |
| 90% | 45.25ms | 414.46us | 109x |
| 95% | 120.65ms | 927.74us | 130x |
| 99% | 182.19ms | 3.78ms | 48x |
| 99.9% | 207.22ms | 205.78ms | 1.0x |
| max | 211.81ms | 210.98ms | 1.0x |
Read the p99 row. A closed-loop tool reports 3.78ms and you conclude the service is healthy. The corrected number is 182ms. Nothing about the server changed between those two rows — only the arithmetic did.
The max agrees in both, and that is the whole point. The closed loop does catch the one request unlucky enough to be in flight when the pause began. What it misses is the thousands of requests that should have been sent during those 200ms and would each have waited their turn behind it. There were 20 connections, so a 200ms pause hides roughly 400 requests' worth of queueing; only 20 of them ever get timed. That is why the two columns converge again at 99.9% — by then you are looking at the stalled requests themselves, which both modes see.
Running 10s test @ http://127.0.0.1:8082/
4 threads, 20 connections, open loop @ 2000 req/s
Latency (corrected)
50% 255.62us
75% 1.70ms
90% 45.25ms
99% 182.19ms
99.9% 207.22ms
max 211.81ms
20000 requests in 10.00s, 1.49MB read
Requests/sec 1999.51
Transfer/sec 152.31KB
Socket errors connect 0, read 0, write 0, timeout 0
Dispatch lag mean 11.87ms, max 194.66ms, 2461 requests behind schedule
note: 2461 of 20000 requests departed late because every connection was
still waiting on the server, not because hammer fell behind. The server
cannot sustain 2000 req/s; the corrected latencies above account for it.
Dispatch lag is how far behind the intended schedule requests actually went out. It is the tool's honesty check, and it distinguishes two very different causes:
- Every connection was busy waiting on the server. That is the server's problem, and the corrected latencies already account for it. hammer says so and does not blame itself.
- A connection was idle and hammer still dispatched late. That is hammer's problem, and it prints
a warning telling you the numbers describe its own bottleneck rather than the server's. Lower
--rateor add threads.
A load generator that admits when it is the problem is worth more than one that doesn't.
--json emits the whole thing as one object — config, percentiles in nanoseconds, throughput,
errors by kind, and dispatch lag.
Level-triggered epoll on Linux, poll everywhere else. Level-triggered on purpose:
edge-triggered means draining to EAGAIN on every wakeup, and one forgotten drain is a hang that
shows up under load a week later. The poll backend is O(n) per tick, so it will not hold up past
a few thousand connections — that is an honest trade for a build whose job is to be downloadable,
not a claim of parity with the epoll build.
Per-thread everything. Connections are sharded across threads at startup. Each thread owns its own histogram, counters and poller; they are merged once at the end. No atomics on the hot path, and no allocation either — there is a test that runs ~90,000 requests and asserts fewer than 200 allocations, so allocation is O(connections), not O(requests).
Timeouts are excluded from the latency histogram. A request that timed out has no latency; it
has an absence of one. Folding the timeout value in would put a fictional number in the tail and
make a broken server look merely slow. They are counted separately under Socket errors, and a
run with a large timeout count should be read as "this did not work", not "this was slow".
A hand-written HDR histogram. Log-linear bucketing, 3 significant digits from 1ns to 60s, O(1) record, fixed 221KB allocated once. Tested against a sorted-vector oracle over 100,000 values at every percentile from 1 to 99.99.
A timer wheel for deadlines, not a per-tick scan over every connection. O(1) arm and disarm, and it allocates nothing after setup.
steady_clock everywhere. The system clock plus NTP silently corrupts timings in a way that is
very hard to notice afterwards.
HTTPS, HTTP/2, redirects, cookies, request bodies streamed from anything but a file, and response body validation. Not macOS-specific tuning, not 32-bit.
ulimit -ndefaults to 1024. Above that many connections you get confusingEMFILEerrors. Raise it before a large run.- Testing a server that sends
Connection: closeburns through the ~28k ephemeral port range in seconds and then blocks inTIME_WAIT. That is the operating system, not a bug in hammer. - Timing under sanitizers is 2-3x slower. Benchmark numbers come from a release build only.
The table above was produced by a Release build (MSVC 19.44, -DCMAKE_BUILD_TYPE=Release, no
sanitizers) on Windows 11 against tools/pausing_server.py on loopback. Linux with epoll is the
build intended for serious benchmarking; these numbers demonstrate the correction, not hammer's
throughput ceiling — a threaded Python server on loopback tops out around 5,300 req/s here, which
is the ceiling being measured in the throughput rows, not hammer's.
cmake --preset asan && cmake --build --preset asan && ctest --preset asan
Presets: asan (ASan+UBSan), tsan, release. The sanitizer presets probe whether the toolchain
can actually link the sanitizer and fall back to a plain Debug build with a warning if it cannot,
so a green local run on a machine without sanitizer runtimes is not evidence of a clean sanitizer
run. CI runs the real thing on Linux, and asserts the sanitizer linked.
CI also builds and tests Release on Linux, macOS and Windows on every push.
There is an optional libFuzzer target for the response parser, clang only:
cmake -S . -B build/fuzz -DHAMMER_FUZZ=ON -DCMAKE_CXX_COMPILER=clang++