Generated 2026-10-28T04:35:00Z by bench/run_http.sh. Do not edit by hand — the next run overwrites it.
Each implementation accepts a TCP connection, parses one HTTP/1.1 request and writes a 14-byte Hello, World!\n body (text/plain), keep-alive. The TLS section runs the same servers and the same load over a self-signed EC (P-256) certificate, which oha accepts with --insecure. The NURL server is the packages/http HttpApp facade (http_app_listen / http_app_listen_tls) in http_app_async mode — fiber per connection on the M:N async runtime, one worker pthread per core, the surface a scaling NURL service deploys (and the same model as the Rust peer's tokio multi-thread runtime).
Read the throughput columns, not the latency columns, at high concurrency. These are closed-loop measurements: oha holds C connections open and fires the next request the instant one returns. If a server's in-flight work saturates below C, the extra connections queue inside oha and never reach the server, so req/s is the server's true saturation throughput but the latency percentiles describe only the few connections in flight. Such cells are marked ‡ and left un-bold: their latency is not a service-level number (that needs an open-loop generator — see Planned rigor). The effective in-flight count is req/s x mean-latency (Little's law).
The CPU us/req row is the one to compare across peers. It is server CPU time (utime+stime over every thread, read externally from /proc/<pid>/stat around the measured window) divided by the exact number of responses — so unlike req/s it does not move when the generator pushes harder or softer. Two warnings that cost real time to learn: it rises with worker count for every runtime (~30 % from 1 to 4 workers here), and an unpinned run inflates it because the generator is stealing the server's cores. Comparing a figure taken at one worker count against a figure taken at another manufactures a peer gap out of nothing but concurrency. The Environment block above states both, for exactly that reason.
| Item | Value |
|---|---|
| Host | GitHub Actions ubuntu-latest runner |
| Kernel | Linux 6.17.0-1022-azure x86_64 |
| CPU | AMD EPYC 9V74 80-Core Processor (4 logical cores) |
| Memory | 16373452 KiB |
| Commit | da5aacbfbb307d0a1aa4f502394df0c540a1c400 |
| CI run | https://github.com/nurl-lang/nurl/actions/runs/40632737368 |
| NURL | v0.79.1-12-gda5aacbf |
| Rust | rustc 1.99.1 (58cf8f9c3 2026-10-13) |
| Node | v22.23.3 |
| Load generator | oha 1.8.0 |
| Setting | Value |
|---|---|
| Throughput/latency | median of 3 x 10 s closed-loop runs, keep-alive |
| Concurrencies | 1 , 10 , 50 , 200 |
| Connection-setup rate | 20000 connections at c=20, --disable-keepalive |
| TLS cert | self-signed EC P-256, CN=localhost |
| Core isolation | server on cores 0-1, generator on cores 2-3 (taskset) |
| Worker threads | 2 per server (NURL_WORKERS / TOKIO_WORKER_THREADS); Node's server is single-threaded |
| Machine load | 1.67 at start (quiet) |
| Server | C = 1 | C = 10 | C = 50 | C = 200 | |
|---|---|---|---|---|---|
| req/s | NURL | 39 999 | 137 952 | 155 117 | 152 240 |
| Rust | 27 864 | 136 985 | 154 552 | 150 464 | |
| Node | 20 546 | 60 529 | 62 106 | 57 923 | |
| p50 (ms) | NURL | 0.02 | 0.07 | 0.35 | 1.22 |
| Rust | 0.04 | 0.07 | 0.35 | 1.24 | |
| Node | 0.05 | 0.14 | 0.77 | 3.33 | |
| p99 (ms) | NURL | 0.03 | 0.10 | 0.41 | 1.62 |
| Rust | 0.05 | 0.10 | 0.42 | 1.64 | |
| Node | 0.07 | 0.35 | 1.56 | 4.54 | |
| CPU us/req | NURL | 25.01 | 11.94 | 11.59 | 11.47 |
| Rust | 15.32 | 12.20 | 11.28 | 10.94 | |
| Node | 31.71 | 16.54 | 16.08 | 17.14 |
| Server | C = 1 | C = 10 | C = 50 | C = 200 | |
|---|---|---|---|---|---|
| req/s | NURL | 36 317 | 114 628 | 127 801 | 128 445 |
| Rust | 25 865 | 113 808 | 126 381 | 119 153 | |
| Node | 16 800 | 47 500 | 47 721 | 43 646 | |
| p50 (ms) | NURL | 0.03 | 0.08 | 0.43 | 1.45 |
| Rust | 0.04 | 0.09 | 0.43 | 1.56 | |
| Node | 0.06 | 0.19 | 1.02 | 4.40 | |
| p99 (ms) | NURL | 0.04 | 0.12 | 0.49 | 1.93 |
| Rust | 0.05 | 0.12 | 0.51 | 2.10 | |
| Node | 0.08 | 0.41 | 1.46 | 5.76 | |
| CPU us/req | NURL | 27.52 | 12.01 | 12.57 | 12.39 |
| Rust | 16.06 | 14.09 | 12.77 | 12.43 | |
| Node | 43.20 | 21.07 | 20.98 | 23.63 |
(Best per row in bold; latency winners are chosen only among non-starved cells. ‡ = closed-loop starved. n/a = tool absent; FAIL = the server did not complete that cell.)
--disable-keepalive, so each request pays a fresh connection. For http that is the accept/teardown rate; for https it is TLS handshakes per second — the pure-NURL P-256 ECDHE + ECDSA-verify path (no OpenSSL, no AES-NI-tier handshake assembly) against rustls and Node. This is the cost a short-lived-connection edge deployment actually pays, and the one the keep-alive tables above amortise to nothing.
| Server | http conn/s | https handshakes/s |
|---|---|---|
| NURL | 26 706 | 9 933 |
| Rust | 26 330 | 7 706 |
| Node | 15 203 | 2 224 |
tokio-rustls; Node through its built-in https module. Each uses its conventional stack, so the columns compare deployments, not just ciphers.Known limits of this harness, in priority order — each is a measurement this run does not yet make, called out so a reader does not have to guess:
oha -q, or wrk2/vegeta) at 50/80/95 % of each server's measured throughput, reporting p50/p99/p99.9/max. Closed loop cannot measure latency above capacity (coordinated omission), which is why saturated cells are marked ‡ rather than trusted.taskset and every runtime that sizes a pool from nproc is given the same worker count. See the Environment block; a run on fewer than 4 cores, or without taskset, says so there instead.getrusage call the original plan wanted in each server: utime+stime read externally from /proc/<pid>/stat is the same figure and needs no code change in any peer, so NURL, Rust and Node are all measured the same way. Divided by the exact response count from oha's status-code histogram, never by rps x duration.