Benchmarks
rws is measured against Caddy and nginx on the same machine, with the same
files and the same load. The numbers below come from one run (40243377254) of the
Benchmark workflow
on a 4-CPU GitHub Actions runner (Linux x86_64), with the load generator on the
same machine.
Load: oha, 64 keep-alive connections,
20 seconds per scenario after a 2-second warm-up, HTTP/1.1, no compression
(--disable-compression: the Caddy and nginx configs don’t compress, and
rws compresses text by default), access logs off everywhere. rws is the
release build (--features "acme auth mimalloc"), Caddy 2.11.7, nginx from Ubuntu with
sendfile, open_file_cache and upstream keep-alive enabled. nginx’s
open_file_cache_valid 1s trusts a cached file’s size and modification time
for a second; rws runs with the equivalent open_file_cache_strict = false
(its default re-checks them with an fstat on every request, so an edit in
place is served at once).
| Scenario | Server | Requests/s | p50 (ms) | p99 (ms) | CPU/request (µs) | Peak memory (MiB) |
|---|---|---|---|---|---|---|
| Static file, 1 KiB | rws | 96,670 | 0.61 | 1.65 | 16.9 | 33.5 |
| Caddy | 18,592 | 2.73 | 13.49 | 154.4 | 66.1 | |
| nginx | 81,161 | 0.59 | 3.10 | 25.1 | 23.2 | |
| Static file, 1 MiB | rws | 9,511 | 6.26 | 14.00 | 209.8 | 33.5 |
| Caddy | 2,398 | 19.03 | 124.03 | 1,194.7 | 69.4 | |
| nginx | 9,767 | 6.42 | 14.05 | 216.2 | 23.4 | |
| Reverse proxy, 1 KiB | rws | 34,260 | 1.79 | 3.89 | 44.6 | 35.5 |
| Caddy | 13,402 | 4.16 | 14.17 | 190.6 | 66.7 | |
| nginx | 33,942 | 1.74 | 4.51 | 47.1 | 24.2 |
CPU/request is the server’s user + system CPU time over the run (including worker processes) divided by the requests it served.
Every request in every scenario returned 200 OK.
Extended scenarios
Section titled “Extended scenarios”The same run, with the extended scenarios (EXTENDED=1, see
Reproduce):
| Scenario | Server | Requests/s | p50 (ms) | p99 (ms) | CPU/request (µs) | Peak memory (MiB) |
|---|---|---|---|---|---|---|
| 26 KiB JS file, gzip | rws | 86,327 | 0.69 | 1.87 | 20.9 | 41.8 |
| Caddy | 5,729 | 8.51 | 51.96 | 606.3 | 109.2 | |
| nginx | 1,802 | 35.26 | 71.94 | 2,063.6 | 27.3 | |
| Static 1 KiB, access log on | rws | 88,643 | 0.66 | 1.81 | 20.2 | 41.6 |
| Caddy | 15,964 | 2.97 | 18.12 | 191.7 | 65.6 | |
| nginx | 75,518 | 0.62 | 3.16 | 28.6 | 23.2 | |
| Static 1 KiB, HTTPS | rws | 70,372 | 0.82 | 2.22 | 25.9 | 49.4 |
| Caddy | 25,555 | 2.22 | 9.57 | 112.6 | 69.4 | |
| nginx | 63,752 | 0.75 | 3.75 | 33.5 | 41.7 | |
| Static 1 KiB, HTTP/2 | rws | 66,141 | 0.90 | 2.44 | 27.4 | 63.7 |
| Caddy | 13,604 | 4.04 | 14.20 | 216.0 | 66.9 | |
| nginx | 51,416 | 0.84 | 4.46 | 37.9 | 42.2 | |
| Static 1 MiB, HTTPS | rws | 1,602 | 35.23 | 49.34 | 923.7 | 65.7 |
| Caddy | 1,460 | 38.13 | 108.27 | 1,483.6 | 68.0 | |
| nginx | 1,311 | 45.19 | 101.34 | 1,639.9 | 42.3 |
rws compresses a static text file once per version and serves the cached
result; nginx and Caddy compress on every request, which is why the gzip row
differs by two orders of magnitude (level 6 everywhere). HTTP/2 is served by
rws’s own implementation (since 18.0.0): with the h2 crate, framing and
HPACK cost more per request than all of nginx’s own code, and nginx led this
scenario by 23–41%. Large files over TLS were slow on AMD
EPYC 7763 runners until 18.0.0: rustls copies each record’s plaintext into
a buffer 5 bytes past a page boundary, and with the file buffer page-aligned
too, glibc’s rep movsb ran that copy at 2.8 instead of 40 GB/s (4 KiB
aliasing). The file buffer now starts mid-page; that scenario went from
nginx +9% to rws −35 to −50%.
On a 10-core Mac (same tools and configuration, oha on the same machine):
1 KiB static — rws 107k, nginx 100k; 1 MiB static — 9.8k vs 9.4k; proxy —
49.0k vs 48.9k requests per second.
Reading the numbers
Section titled “Reading the numbers”- Against Caddy:
rwsserves 4.0× (large) to 5.2× (small) as many file requests, 2.6× as many proxied and 4.9× as many HTTP/2 requests per second, using a quarter to a ninth of the CPU per request, with a p99 three to nine times lower and about half the memory. - Against nginx: less CPU per request in every scenario — small files
−33%, large files −3%, proxying −5%, HTTP/2 −28%, HTTPS −23%, large files
over HTTPS −44%, with an access log −29%, with gzip a hundredth — more
requests per second in all but large plain files (−3%), and the lower p99
everywhere. This run used an AMD EPYC runner. On the fastest (Intel)
runner type nginx keeps 6–8% more plain-HTTP requests per second and
about the same CPU for large files and proxying (run 40209996636);
RWS_CONFIG_RUNTIME=per-corenarrows that (run 40242045036). The load generator shares the runner’s 4 CPUs, so the CPU/request column is the comparison to read. - Memory: release builds allocate with mimalloc, which costs about 5%
less CPU per request than the system allocator but holds more memory —
34–66 MiB here, against nginx’s 23–42. Built without the
mimallocfeature,rwspeaks at 9–17 MiB. - The gap depends on the runner’s hardware; same-run A/Bs (
base_ref, below) are how changes are judged. - Shared CI runners are noisy: absolute numbers vary by a factor of two between runs. Compare servers within one run, not numbers across runs.
- Compression matters: with
Accept-Encoding: gzipsent and onlyrwscompressing, the comparison measuresgzip, not the servers. Compare like for like.
Why it’s fast
Section titled “Why it’s fast”- Open files are reused (an open-file cache like nginx’s): a request for
a hot file costs one
fstat; files up to 64 KiB are served from memory, and the response head and body go out in one write. - Quick requests run on the event loop. Static files, health checks and
metrics skip the hand-off to a blocking thread pool, as in nginx. Handlers
that may block (your routes) still get a pool thread, so a slow handler
can’t stall other connections. See
runs_inline. - Proxying holds no thread. Proxy routes without middleware, to HTTP or HTTPS backends, wait for the backend on the connection’s task, over pooled keep-alive connections.
- One idle timer per connection, pushed back on each read without a lock.
sendfilefor downloads over plain HTTP on Linux and macOS: the kernel copies file pages straight to the socket, with the response head in the same segment (TCP_CORKon Linux,sendfile’s header vector on macOS).- Configuration is a cached snapshot, not read from the environment on every request; a reload replaces it.
- Few allocations per request. The standard response headers are
borrowed
&'static strs (Headerfields areCow<'static, str>), so attaching them copies nothing; request heads and upstream response heads are parsed in one pass over the bytes; numbers are formatted on the stack; the file check is onefstat.
Reproduce
Section titled “Reproduce”Run the workflow from the repository’s Actions tab (“Benchmark” → “Run workflow”), or locally:
cargo build --release --bin rws --features "acme auth"RWS=target/release/rws OHA=oha CADDY=caddy NGINX=nginx bench/run.shCADDY and NGINX are optional; the proxy scenario needs NGINX, which also
serves as the shared backend for every proxy under test. DURATION (default
15s) and CONNECTIONS (default 64) tune the load. The script prints a
Markdown table and writes bench-results.md and bench-results.json.
EXTENDED=1 (on by default in the workflow) adds the paths real deployments
hit that the three scenarios above leave out, each with equivalent settings
on every server: text-gzip (a 26 KiB JavaScript file with
Accept-Encoding: gzip, gzip level 6 everywhere), log-1k (an access log
written to a file), https-1k and h2-1k (TLS over HTTP/1.1 and HTTP/2),
and https-1m (a 1 MiB file over TLS, where sendfile doesn’t apply). The
script makes a self-signed certificate with openssl, and skips a TLS
scenario for an nginx built without its ssl or http2 module.
Profile
Section titled “Profile”To see where rws spends its CPU, run the workflow with profile checked:
it builds with frame pointers and samples rws with perf during each
scenario, and nginx’s workers too (nginx-<scenario> reports; the distro
nginx has no symbols, but its kernel time is comparable with rws’s). The job summary lists the hottest symbols, and the bench-profile
artifact has flat and call-tree reports plus perf script output for flame
graphs (inferno-collapse-perf | inferno-flamegraph). Locally, on Linux:
RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release --bin rws --features "acme auth"PERF_DIR=perf RWS=target/release/rws OHA=oha NGINX=nginx bench/run.shperf report -i perf/static-1k.data --no-childrenSampling slows rws down, so take throughput numbers from an unprofiled run.
Compare two builds
Section titled “Compare two builds”Runner hardware differs from run to run (nginx itself varies threefold), so
a change can only be judged against its baseline in the same run. Give the
workflow a base_ref (a branch, tag or commit) and it also builds that
ref and benches it as rws-base, right after rws; locally, set
RWS_BASE=<binary>. base_workers (RWS_BASE_WORKERS) runs rws-base
with that many tokio workers — with no base ref, the same build again — to
compare worker counts, and base_runtime (RWS_BASE_RUNTIME) does the same
for RWS_CONFIG_RUNTIME (shared or per-core). (Fewer workers than CPUs raises the measured
requests per second only because the load generator shares the runner’s
CPUs; on a dedicated server one worker per core is right.)