Benchmarks
2026-10-08 — scalws vs nginx 1.24 / 1.30.5 / 1.31.6, synthetic and real applications
Host: development VM (8 vCPU EPYC 7642, Ubuntu 24.04). Server on CPUs 0-3, applications
and upstreams on 4-5 (MariaDB pinned there too), oha on 6-7. MODE=release RUNS=3 FRESH_SERVER=1 (3 × 30 s per scenario after 10 s warm-up, every scenario on a freshly
started server and applications, unit order rotating). Raw results:
results/2026-10-08-full-vs-nginx/, Laravel rerun
results/2026-10-08-laravel-rerun/. Tables from
bench/matrix.py.
- scalws —
scalwsdwith the security fixes and the first per-request savings (commit eba8dc4), release,--features http3 - nginx 1.24.0 — Ubuntu package; 1.30.5 (stable) and 1.31.6 (mainline) — built
from the signed nginx.org sources (
-O2, pcre-jit, threads, ssl, http_v2, http_v3, realip, stub_status, gzip_static, auth_request), same configuration - Real applications (
bench/real/setup.sh): WordPress 6 (PHP-FPM 4 workers, MariaDB, pretty permalinks — scalws via its.htaccess, nginx viatry_files), Laravel 12 (production, config/route/view cache, file sessions), Express 4 (HTML page and JSON API), Django 5 (gunicorn, 2 workers, template page)
Requests/s (median of 3)
| Scenario | scalws | nginx 1.24 | nginx 1.30.5 | nginx 1.31.6 | vs 1.24 | vs 1.30.5 | vs 1.31.6 |
|---|---|---|---|---|---|---|---|
| static-small | 95 009 | 102 097 | 100 891 | 101 613 | 0.93× | 0.94× | 0.94× |
| static-1m | 8 906 | 9 180 | 9 112 | 9 291 | 0.97× | 0.98× | 0.96× |
| respond | 106 233 | 100 763 | 103 737 | 99 903 | 1.05× | 1.02× | 1.06× |
| proxy | 80 343 | 93 128 | 93 523 | 93 415 | 0.86× | 0.86× | 0.86× |
| cache-hit | 101 781 | 103 051 | 101 438 | 100 649 | 0.99× | 1.00× | 1.01× |
| TLS + HTTP/2 static | 61 062 | 62 963 | 63 105 | 63 254 | 0.97× | 0.97× | 0.97× |
| PHP-FPM | 30 173 | 26 578 | 26 514 | 25 472 | 1.14× | 1.14× | 1.18× |
| Node.js | 26 570 | 26 480 | 24 919 | 25 285 | 1.00× | 1.07× | 1.05× |
| Python (uvicorn) | 11 800 | 12 147 | 11 463 | 11 800 | 0.97× | 1.03× | 1.00× |
| WordPress | 37 | 35 | 36 | 37 | 1.06× | 1.03× | 1.00× |
| Laravel (rerun) | 376 | 394 | 370 | 381 | 0.95× | 1.02× | 0.99× |
| Express HTML | 3 618 | 3 410 | 3 869 | 3 500 | 1.06× | 0.94× | 1.03× |
| Express JSON | 3 515 | 3 638 | 3 641 | 3 610 | 0.97× | 0.97× | 0.97× |
| Django | 1 987 | 1 985 | 1 962 | 1 928 | 1.00× | 1.01× | 1.03× |
Run-to-run spread is 1–5 % (more for static-small, which measured 102 413 in run 1); differences below that are not results. Newer nginx versions are not faster than 1.24 here.
Latency, CPU and memory
- p99: scalws much lower for TLS + HTTP/2 (2.9 vs 10.6–11.2 ms) and static-small (6.4 vs 8.4–9.6 ms), lower for respond and PHP; higher for proxy (1.86 vs 1.39–1.46 ms) and static-1m (17.1 vs 13.8–15.0 ms).
- Server CPU per request: scalws lower for static-1m (274 vs 315–320 µs), cache hits (25.7 vs 31.9–32.6), TLS and Laravel (315 vs 499–534 µs); higher for static-small (36.7 vs 30), proxy (49.2 vs 36.5–39.2) and the low-rate proxied applications (Express 120 vs 88–97, Django 175 vs 129–136). Idle CPU is ~0 for both servers, so the low-rate gap is per-request work (likely event-loop wake-ups that no batching amortises at a few thousand requests/s) — open item.
- Peak RSS: scalws 45–58 MiB vs nginx 77–90 MiB in every scenario.
Laravel: the first numbers were a fixture artefact
The full run showed Laravel at 95 vs 133–145 req/s. Cause: oha sends no cookies, so every
request opened a new file session; the session directory had grown to 65 082 files
(259 MiB), Laravel’s session GC scans it on 2 % of requests, and whichever server was
measured later met a larger directory. run.sh now empties the session directory before
each Laravel measurement; the rerun above is at parity. Laravel runs through FastCGI
here (PHP-FPM), not the HTTP proxy, so the upstream HTTP client is not involved.
Correctness check (same Laravel, scalws vs nginx 1.30.5): the home page response is the same
(status, 70 403-byte body, chunked framing, headers apart from case and nginx’s
Connection: keep-alive); an empty POST gets 405 from both; a 300 KB request body —
plain and chunked — reaches PHP byte-identical (same SHA-1) through both. FastCGI
parameters differ only where intended: scalws adds X-Forwarded-*/X-Request-Id, sends
CONTENT_LENGTH=0 for bodyless requests and does not duplicate CONTENT_* as HTTP_*
(RFC 3875). SERVER_PORT follows the Host header’s port (scheme default without one, as
Apache with UseCanonicalName Off); real clients always send the port when it is not the
default.
Proxy: what the gap is made of
At a fixed 40 000 req/s scalws’s proxy uses less CPU per request than nginx (49 vs 58 µs); only at saturation does nginx get cheaper (its kernel cost per request falls with batching). Profiled at saturation: kernel time per request is about the same (31.8 vs 34.5 µs); the difference is user space, 17.0 µs in scalwsd vs 5.8 µs in nginx, spread over hyper (2.6), tokio scheduling (2.6), allocation (2.1, 32 mallocs per proxied request), the request pipeline (2.0), moves of large futures (1.6), header maps (1.5) and the proxy (1.3).
An own HTTP/1 upstream client (no dispatcher task, no body channel) was written and kept
out of the build (docs/experimental/own-h1-client.patch): it implements message framing
and chunked decoding itself, which is not to be hand-written for production. The next
steps keep hyper’s HTTP/1 protocol code and work on connection management and
allocations around it.
Found while testing the proxy against scripted upstreams: a response carrying both
Transfer-Encoding and Content-Length was forwarded with the stale Content-Length,
and its connection was reused. Both are fixed (RFC 9112 §6.1, §6.3).