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 — scalwsd with 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 via try_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)

Scenarioscalwsnginx 1.24nginx 1.30.5nginx 1.31.6vs 1.24vs 1.30.5vs 1.31.6
static-small95 009102 097100 891101 6130.93×0.94×0.94×
static-1m8 9069 1809 1129 2910.97×0.98×0.96×
respond106 233100 763103 73799 9031.05×1.02×1.06×
proxy80 34393 12893 52393 4150.86×0.86×0.86×
cache-hit101 781103 051101 438100 6490.99×1.00×1.01×
TLS + HTTP/2 static61 06262 96363 10563 2540.97×0.97×0.97×
PHP-FPM30 17326 57826 51425 4721.14×1.14×1.18×
Node.js26 57026 48024 91925 2851.00×1.07×1.05×
Python (uvicorn)11 80012 14711 46311 8000.97×1.03×1.00×
WordPress373536371.06×1.03×1.00×
Laravel (rerun)3763943703810.95×1.02×0.99×
Express HTML3 6183 4103 8693 5001.06×0.94×1.03×
Express JSON3 5153 6383 6413 6100.97×0.97×0.97×
Django1 9871 9851 9621 9281.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).