Benchmarks
Experimental upstream client vs hyper (2026-10-09)
ADR-0045. One scalwsd build (commit d7dcbf5, --features http3), run with
server.upstream_client: hyper (default) and experimental. Development VM: 4 server
CPUs (0-3), upstreams on 4-5, load generator on 6-11. bench/multi.sh, MODE=quick
(3 s warm-up, 10 s measurement), 64 connections, variants alternate in every run.
Round 1 — 5 runs (results/2026-10-09-own-client-ab)
| Scenario | hyper req/s | experimental req/s | Δ | CPU µs/req hyper → exp. | p99 ms |
|---|---|---|---|---|---|
| proxy (synthetic upstream) | 75 142 | 83 653 | +11.3 % | 52.2 → 46.4 | 1.83 → 1.60 |
| node (managed) | 27 045 | 26 725 | −1.2 % | 68.0 → 62.8 | 5.12 → 4.64 |
| python (ASGI, uvicorn) | 11 375 | 10 665 | −6.2 % ¹ | 104.2 → 100.9 | 10.5 → 12.5 |
| Express | 3 645 | 4 096 | +12.4 % ² | 121.2 → 110.7 | 32.3 → 30.7 |
| Express API | 3 103 | 3 401 | +9.6 % | 123.4 → 113.8 | 40.1 → 35.5 |
| Django (gunicorn) | 1 891 | 1 867 | −1.3 % | 179.5 → 164.4 | 59.8 → 60.3 |
| Laravel (FastCGI — control, not affected) | 521 | 511 | −1.9 % | 328 → 313 | — |
¹ Re-measured, see round 2. ² CV 12–18 %.
Round 2 — 10 runs, python and Django (results/2026-10-09-own-client-py)
| Scenario | hyper req/s | experimental req/s | Δ | CPU µs/req hyper → exp. |
|---|---|---|---|---|
| python (ASGI) | 11 244 | 11 332 | +0.8 % | 103.8 → 92.3 |
| Django | 1 913 | 1 900 | −0.7 % | 177.3 → 158.3 |
The round-1 python difference was noise (CV 3–4 % with 5 runs; the Laravel control moved by 2 % as well). Syscalls per request are the same for both clients (strace, 20 000 requests: 2 reads + 2 writes per request each).
Conclusion
- The experimental client lowers scalwsd’s CPU per proxied request by 7–11 % on every upstream measured, and raises synthetic proxy throughput by 11 %, where the upstream is not the bottleneck.
- Real applications are bound by the application (Node, Python, PHP); their throughput is unchanged within noise, CPU left for them grows.
- No real-application regression was measured; it stays opt-in (ADR-0045): message framing is our own code, and the hyper path is the conservative default. A long soak (roadmap: 6–12 h real-app tests) is the next gate before it could become the default.
Remaining work inside hyper’s path
- Response cache on proxied routes. The benchmark’s proxy route passes through the
response cache (nginx’s
location /api/has none). Excluding it withbypass_paths: [/api/x]changes nothing measurable: 75 106 vs 74 141 req/s, 52.2 vs 52.6 µs CPU per request (5 runs,results/2026-10-09-proxy-cache-bypass). Bypass rules are now checked before the cache key is built (19126c2), so bypassed requests no longer pay for it. - Allocations. A proxied request makes 21 allocations (32 on 2026-10-08). Removing the few left on the request path is worth about 1–2 % — below what ADR-0038 accepts as a measurable gain, so not pursued.
On hyper’s path the proxy is at diminishing returns; the experimental client is the measured lever (+11 %).