Benchmarks
2026-10-09 — Final comparison: scalws vs nginx 1.24 / 1.30.5 / 1.31.6
Overnight work (2026-10-08/09): Stage 1 (upstream path), Stage 2 (upstream telemetry),
the open items, then this comparison. Host and method as in
2026-10-08-nginx-versions-real-apps.md: 8 vCPU
EPYC 7642 VM, server on CPUs 0-3, applications/upstreams/MariaDB on 4-5, oha on 6-7,
every scenario on a freshly started server (FRESH_SERVER=1), unit order rotating.
- scalws — commit bfab683 (security fixes, per-request savings, upstream telemetry),
release,
--features http3 - nginx — 1.24.0 (Ubuntu package), 1.30.5 stable and 1.31.6 mainline (signed
nginx.org sources,
-O2)
Raw results: results/2026-10-09-final-vs-nginx/
(3 × 30 s, all scenarios) and results/2026-10-09-final-apps8/
(8 × 10 s, the application scenarios whose run-to-run spread is 5–15 %).
Requests/s (median)
| 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 | 97 596 | 99 363 | 101 401 | 102 396 | 0.98× | 0.96× | 0.95× |
| static-1m | 8 894 | 9 362 | 9 341 | 9 381 | 0.95× | 0.95× | 0.95× |
| respond | 104 303 | 102 140 | 102 395 | 97 963 | 1.02× | 1.02× | 1.06× |
| proxy | 80 398 | 92 622 | 94 396 | 98 935 | 0.87× | 0.85× | 0.81× |
| cache-hit | 102 443 | 100 593 | 98 105 | 101 346 | 1.02× | 1.04× | 1.01× |
| TLS + HTTP/2 static | 61 799 | 64 379 | 63 840 | 63 651 | 0.96× | 0.97× | 0.97× |
| PHP-FPM | 27 462 | 25 269 | 24 361 | 26 217 | 1.09× | 1.13× | 1.05× |
| WordPress | 37 | 36 | 37 | 36 | 1.03× | 1.00× | 1.03× |
| Laravel | 385 | 371 | 392 | 357 | 1.04× | 0.98× | 1.08× |
| Node.js (8 runs) | 26 807 | 26 020 | 25 616 | 26 263 | 1.03× | 1.05× | 1.02× |
| Python uvicorn (8 runs) | 11 318 | 11 189 | 11 022 | 11 177 | 1.01× | 1.03× | 1.01× |
| Express HTML (8 runs) | 3 697 | 3 829 | 3 702 | 3 373 | 0.97× | 1.00× | 1.10× |
| Express JSON (8 runs) | 3 441 | 3 656 | 3 431 | 3 550 | 0.94× | 1.00× | 0.97× |
| Django (8 runs) | 1 911 | 1 931 | 1 929 | 1 920 | 0.99× | 0.99× | 1.00× |
Differences below about 3 % (5–10 % for Express) are within the run-to-run spread.
p99 latency, server CPU per request, memory
| Scenario | p99 ms scalws / nginx 1.24–1.31.6 | CPU µs/req scalws / nginx | RSS MiB scalws / nginx |
|---|---|---|---|
| static-small | 1.9 / 6.9–9.4 | 34.5 / 29.0–31.5 | 46 / 77–78 |
| static-1m | 17.2 / 14.9–15.9 | 298.7 / 308–317 | 45 / 77–78 |
| respond | 9.4 / 10.2–10.5 | 21.3 / 21.8–23.2 | 45 / 76–78 |
| proxy | 1.68 / 1.47–1.52 | 49.0 / 36.1–38.5 | 52 / 78–79 |
| cache-hit | 9.2 / 3.3–6.8 | 26.0 / 34.2–35.0 | 46 / 78–79 |
| TLS + HTTP/2 | 2.5 / 10.4–10.5 | 43.0 / 43.7–44.2 | 53 / 89–90 |
| PHP-FPM | 3.4 / 4.3–4.4 | 82.8 / 92.5–93.2 | 48 / 78 |
| WordPress | 2 285 / 2 479–2 980 | 747 / 701–740 | 52 / 79–80 |
| Laravel | 459 / 423–491 | 330 / 496–501 | 61 / 79–80 |
| Express JSON | 33.6 / 30.1–32.4 | 123 / 90–98 | 50 / 78–79 |
| Django | 58.8 / 49.9–57.4 | 176 / 128–138 | 50 / 78–79 |
Summary
- Ahead or level: PHP-FPM (+5–13 %), respond, cache hits, Node.js, Python, WordPress, Laravel, Django, and TLS + HTTP/2 latency (p99 2.5 ms vs 10.5 ms). PHP, Laravel, cache hits and 1 MiB files cost less server CPU per request than with nginx.
- Behind: the bare reverse proxy (0.81–0.87×) and, by 2–5 %, small and 1 MiB static files. Newer nginx versions are not faster than 1.24 except on the bare proxy.
- Memory: scalws 45–61 MiB vs nginx 76–90 MiB in every scenario.
What the night changed and what is left
Stage 1 — upstream path. Allocation work, measured per request with uprobes on the
allocator: static file 40 → 21 allocations (derived headers memoised per thread and
validated against each fstat), respond 16 → 13, proxy 34 → 30; CPU per request static
39.8 → 37.2 µs, respond 25.4 → 23.2 µs (A/B in
results/2026-10-09-alloc-ab/). An apparent Express
regression in that A/B was noise: 10 alternating runs show none
(results/2026-10-09-express-ab10/). Two upstream
correctness bugs found by new framing tests were fixed (Transfer-Encoding + Content-Length
responses). The own HTTP/1 client is kept out of the build
(docs/experimental/own-h1-client.patch): it framed messages itself.
Stage 2 — upstream telemetry (ADR-0043): connections opened/reused, retries,
cancellations, wait and time-to-first-byte per application and protocol (HTTP/1 and
FastCGI), at no measurable cost (results/2026-10-09-telemetry-ab/).
First readings: proxy 32 connections for 20 000 requests; PHP-FPM 4 connections for 5 000
requests, 0.49 ms average queue wait at 16 concurrent requests on 4 workers.
Open items, resolved as one cause. The higher CPU per request of the low-rate proxied applications (Express, Django, ~3.5k req/s) is not wake-ups: at a fixed 3 500 req/s scalws has fewer context switches per request than nginx (1.94 vs 2.27) and still about 13 µs more CPU (112 vs 99 µs). That is the same constant user-space cost as the proxy gap at saturation (17 µs per request in scalwsd vs 5.8 µs in nginx; kernel time is equal), spread over hyper’s HTTP/1 server and client machinery, tokio scheduling and the pipeline. hyper’s header-read timer stays (Slowloris protection). Closing the gap means a leaner HTTP/1 path than hyper’s — the own client was the attempt, deliberately not built.