← Playground bench/HTTP_RESULTS.md
raw

NURL HTTP-server peer-comparison

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.

Environment

ItemValue
HostGitHub Actions ubuntu-latest runner
KernelLinux 6.17.0-1022-azure x86_64
CPUAMD EPYC 9V74 80-Core Processor (4 logical cores)
Memory16373452 KiB
Commitda5aacbfbb307d0a1aa4f502394df0c540a1c400
CI runhttps://github.com/nurl-lang/nurl/actions/runs/40632737368
NURLv0.79.1-12-gda5aacbf
Rustrustc 1.99.1 (58cf8f9c3 2026-10-13)
Nodev22.23.3
Load generatoroha 1.8.0
SettingValue
Throughput/latencymedian of 3 x 10 s closed-loop runs, keep-alive
Concurrencies1 , 10 , 50 , 200
Connection-setup rate20000 connections at c=20, --disable-keepalive
TLS certself-signed EC P-256, CN=localhost
Core isolationserver on cores 0-1, generator on cores 2-3 (taskset)
Worker threads2 per server (NURL_WORKERS / TOKIO_WORKER_THREADS); Node's server is single-threaded
Machine load1.67 at start (quiet)

1. Plaintext HTTP

ServerC = 1C = 10C = 50C = 200
req/sNURL39 999137 952155 117152 240
Rust27 864136 985154 552150 464
Node20 54660 52962 10657 923
p50 (ms)NURL0.020.070.351.22
Rust0.040.070.351.24
Node0.050.140.773.33
p99 (ms)NURL0.030.100.411.62
Rust0.050.100.421.64
Node0.070.351.564.54
CPU us/reqNURL25.0111.9411.5911.47
Rust15.3212.2011.2810.94
Node31.7116.5416.0817.14

2. TLS (HTTPS)

ServerC = 1C = 10C = 50C = 200
req/sNURL36 317114 628127 801128 445
Rust25 865113 808126 381119 153
Node16 80047 50047 72143 646
p50 (ms)NURL0.030.080.431.45
Rust0.040.090.431.56
Node0.060.191.024.40
p99 (ms)NURL0.040.120.491.93
Rust0.050.120.512.10
Node0.080.411.465.76
CPU us/reqNURL27.5212.0112.5712.39
Rust16.0614.0912.7712.43
Node43.2021.0720.9823.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.)

3. Connection-setup rate (new connection per request)

--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.

Serverhttp conn/shttps handshakes/s
NURL26 7069 933
Rust26 3307 706
Node15 2032 224

Notes

Planned rigor

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:

  1. Open-loop latency. Replace the closed-loop latency columns with a fixed-rate generator (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.
  2. ~~Core isolation.~~ Done — the server and the generator run on disjoint core sets via 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.
  3. ~~CPU-time per request.~~ Done, and without the 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.
  4. Record-layer throughput. Re-run TLS with 16 KB and 1 MB bodies; a 14-byte body exercises the handshake and framing, not the AEAD stream.