Benchmarks

Every number here comes from a public CI run you can open and rerun. Compare servers within one run: shared runners vary threefold between runs.

rws, nginx and Caddy

Requests per second

Static file, 1 KiB Higher is better
rws 96,670 req/s
Caddy
nginx
Static file, 1 MiB Higher is better
rws 9,511 req/s
Caddy
nginx
Reverse proxy, 1 KiB Higher is better
rws 34,260 req/s
Caddy
nginx
26 KiB JS file, gzip Higher is better
rws 86,327 req/s
Caddy
nginx
Static 1 KiB, access log on Higher is better
rws 88,643 req/s
Caddy
nginx
Static 1 KiB, HTTPS Higher is better
rws 70,372 req/s
Caddy
nginx
Static 1 KiB, HTTP/2 Higher is better
rws 66,141 req/s
Caddy
nginx
Static 1 MiB, HTTPS Higher is better
rws 1,602 req/s
Caddy
nginx

CPU per request

Static file, 1 KiB Lower is better
rws 16.9 µs
Caddy
nginx
Static file, 1 MiB Lower is better
rws 209.8 µs
Caddy
nginx
Reverse proxy, 1 KiB Lower is better
rws 44.6 µs
Caddy
nginx
26 KiB JS file, gzip Lower is better
rws 20.9 µs
Caddy
nginx
Static 1 KiB, access log on Lower is better
rws 20.2 µs
Caddy
nginx
Static 1 KiB, HTTPS Lower is better
rws 25.9 µs
Caddy
nginx
Static 1 KiB, HTTP/2 Lower is better
rws 27.4 µs
Caddy
nginx
Static 1 MiB, HTTPS Lower is better
rws 923.7 µs
Caddy
nginx

Peak memory

nginx uses the least memory in every scenario. Release builds of rws use the mimalloc allocator: less CPU, more memory; a build without it peaks around 9 MiB.

Static file, 1 KiB Lower is better
rws 33.5 MiB
Caddy
nginx
Static file, 1 MiB Lower is better
rws 33.5 MiB
Caddy
nginx
Reverse proxy, 1 KiB Lower is better
rws 35.5 MiB
Caddy
nginx
26 KiB JS file, gzip Lower is better
rws 41.8 MiB
Caddy
nginx
Static 1 KiB, access log on Lower is better
rws 41.6 MiB
Caddy
nginx
Static 1 KiB, HTTPS Lower is better
rws 49.4 MiB
Caddy
nginx
Static 1 KiB, HTTP/2 Lower is better
rws 63.7 MiB
Caddy
nginx
Static 1 MiB, HTTPS Lower is better
rws 65.7 MiB
Caddy
nginx

Gateway API implementations

Envoy Gateway v1.9.2, NGINX Gateway Fabric 2.7.2, Traefik (chart 41.7.1), kgateway v2.4.6, Istio 1.30.5, rws 18.8.1. More in the Gateway API docs, including what this does not measure.

Requests per second

HTTP/1.1, 1 KiB Higher is better
rws 38,501 req/s
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTP/1.1, 1 KiB, 256 conns Higher is better
rws 39,230 req/s
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTP/1.1, 1 MiB Higher is better
rws 2,467 req/s
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTPS, 1 KiB Higher is better
rws 34,792 req/s
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTP/2 + TLS, 1 KiB Higher is better
rws 32,120 req/s
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
Header modifiers + rewrite, 1 KiB Higher is better
rws 36,821 req/s
kgateway
Istio
Traefik
Envoy Gateway

NGINX Gateway Fabric answered 404 through the rewriting route, so it is left out.

CPU per request

HTTP/1.1, 1 KiB Lower is better
rws 40 µs
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTP/1.1, 1 KiB, 256 conns Lower is better
rws 19 µs
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTP/1.1, 1 MiB Lower is better
rws 928 µs
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTPS, 1 KiB Lower is better
rws 33 µs
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
HTTP/2 + TLS, 1 KiB Lower is better
rws 63 µs
kgateway
Istio
Traefik
Envoy Gateway
NGINX GF
Header modifiers + rewrite, 1 KiB Lower is better
rws 27 µs
kgateway
Istio
Traefik
Envoy Gateway

NGINX Gateway Fabric answered 404 through the rewriting route, so it is left out.

All numbers and the method

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

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

Why it's fast

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

CADDY 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

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.sh
perf report -i perf/static-1k.data --no-children

Sampling slows rws down, so take throughput numbers from an unprofiled run.

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