Kubernetes ingress benchmark
ScalWS in ingress mode against ingress-nginx and Traefik on one k3s node: the same backend, the same Ingress objects, one controller installed at a time. With its default accept policy ScalWS is 1.29× tuned ingress-nginx in the tested HTTP/1.1 small-response workload, 1.55× in the tested TLS + HTTP/2 workload and 2.46× with a new TLS connection per request. Against the best ingress-nginx setting for each scenario the range is 1.10× (HTTP/1.1, small response) to 2.44× (TLS, new connection per request).
One machine: controller, backend and load generator share a host (the load generator on the other CPU socket), one node, one controller replica, traffic sent straight to the controller pod. ingress-nginx is retired upstream (2026); it is the reference because it is still the most widely deployed controller. ScalWS commit 4823999, measured 2026-10-14. Limits and where ScalWS loses → Methodology →
tuned ingress-nginx · HTTP/1.1, small response
tuned ingress-nginx · TLS, HTTP/2
tuned ingress-nginx · TLS, new connection per request
Throughput
Five scenarios at full load. ScalWS runs with the default accept policy (shared); the opt-in fountain policy is shown as a secondary variant. ingress-nginx runs with its chart defaults and tuned for this workload (settings); Traefik with its chart defaults.
ScalWS vs ingress-nginx tuned
- HTTP/1.1, small response1.29×
- HTTP/1.1, 100 KiB response1.19×
- TLS, HTTP/1.11.36×
- TLS, HTTP/21.55×
- TLS, new connection per request2.46×
ScalWS (shared accept policy) requests/s divided by tuned ingress-nginx requests/s, as published. Right of the 1.00× line: ScalWS faster. Symmetric log scale.
ScalWS vs the best ingress-nginx setting
- HTTP/1.1, small response1.10×
- HTTP/1.1, 100 KiB response1.19×
- TLS, HTTP/1.11.17×
- TLS, HTTP/21.36×
- TLS, new connection per request2.44×
The same, against whichever ingress-nginx configuration was fastest in each scenario (defaults, keep-alive settings only, or tuned), as published.
Requests per second, every controller
- HTTP/1.1, small response
- HTTP/1.1, 100 KiB response
- TLS, HTTP/1.1
- TLS, HTTP/2
- TLS, new connection per request
Requests per second, median of 3 runs of 30 s, 256 requests in flight. Higher is better. Each row is scaled to its own largest value.
| Scenario | ScalWS shared | ScalWS fountain | ingress-nginx default | ingress-nginx tuned | Traefik | vs ingress-nginx defaults | vs ingress-nginx tuned | vs best ingress-nginx | vs Traefik |
|---|---|---|---|---|---|---|---|---|---|
| HTTP/1.1, small response | 64.3k | 59.8k | 58.2k | 49.9k | 39.6k | 1.11× | 1.29× | 1.10× | 1.62× |
| HTTP/1.1, 100 KiB response | 29.0k | 27.3k | 12.3k | 24.3k | 19.5k | 2.35× | 1.19× | 1.19× | 1.49× |
| TLS, HTTP/1.1 | 61.2k | 57.6k | 51.4k | 45.1k | 36.9k | 1.19× | 1.36× | 1.17× | 1.66× |
| TLS, HTTP/2 | 62.4k | 57.4k | 44.4k | 40.2k | 28.1k | 1.41× | 1.55× | 1.36× | 2.22× |
| TLS, new connection per request | 13.5k | 13.0k | 5.53k | 5.49k | 6.01k | 2.44× | 2.46× | 2.44× | 2.25× |
“vs tuned” and “vs best” are as published in the report; “vs defaults” and “vs Traefik” are computed from the same requests/s.
| Scenario | ScalWS shared p50 / p99 | ScalWS fountain p50 / p99 | ingress-nginx default p50 / p99 | ingress-nginx tuned p50 / p99 | Traefik p50 / p99 | ScalWS shared errors | ScalWS fountain errors | ingress-nginx default errors | ingress-nginx tuned errors | Traefik errors |
|---|---|---|---|---|---|---|---|---|---|---|
| HTTP/1.1, small response | 2.74 / 16.74 | 1.25 / 22.00 | 3.46 / 16.99 | 4.00 / 19.96 | 5.24 / 22.93 | 0 | 0 | 0 | 0 | 0 |
| HTTP/1.1, 100 KiB response | 6.54 / 32.64 | 2.48 / 57.83 | 17.78 / 70.49 | 8.56 / 36.12 | 10.11 / 50.56 | 0 | 0 | 0 | 0 | 0 |
| TLS, HTTP/1.1 | 2.91 / 17.80 | 1.55 / 22.28 | 3.81 / 20.20 | 4.34 / 21.93 | 5.54 / 24.78 | 0 | 0 | 0 | 0 | 0 |
| TLS, HTTP/2 | 3.15 / 15.18 | 2.33 / 23.00 | 4.27 / 20.75 | 5.16 / 21.26 | 7.72 / 28.86 | 0 | 0 | 15,560 | 1,092 | 0 |
| TLS, new connection per request | 17.22 / 46.70 | 17.30 / 53.45 | 35.22 / 183.99 | 34.74 / 200.28 | 26.74 / 166.79 | 0 | 0 | 0 | 0 | 0 |
Latencies are medians of the runs; errors are non-2xx answers plus client errors. The load generator is closed-loop, so these latencies include queueing at full load. The ingress-nginx HTTP/2 errors are streams cut by its keep-alive request limit (GOAWAY).
Latency, backend churn and Ingress churn
Three scenarios at a fixed 19,000 req/s over HTTP/1.1 keep-alive. In the first nothing changes. In backend churn the backend Deployment is scaled 4 to 8 to 2 to 4 pods during each run, and the pods have no preStop delay, so a removed pod stops while controllers may still send to it. In Ingress churn an unrelated Ingress is added or removed every 2 s.
- 0
failed requests for ScalWS in backend churn (3 runs). ingress-nginx: 5,979 (defaults) and 8,512 (tuned); Traefik: 13,690.
- 0
failed requests for ScalWS in Ingress churn. ingress-nginx: 826 and 981 (its reloads close client keep-alive connections); Traefik: 0.
- 1.56 ms
ScalWS p99 at 19,000 req/s; 1.70 ms in backend churn and 1.60 ms in Ingress churn. Tuned ingress-nginx: 2.05 ms quiet.
Fixed rate: p99 and failed requests
- Fixed rate, quiet: p99
- Backend churn: failed requests
- Backend churn: p99
- Ingress churn: failed requests
- Ingress churn: p99
19,000 req/s, 3 runs of 60 s. p99 is the "clean" p99 (median over the runs, without the first second and the seconds in which the load generator itself stalled); failed requests are non-2xx answers plus client errors, summed over the runs. Lower is better.
| Scenario, controller | p50 ms | p99 ms | p99.9 ms | worst 1 s p99 ms | failed | over 1 s | CPU cores | peak RSS MiB |
|---|---|---|---|---|---|---|---|---|
| Fixed rate, quiet ScalWS shared | 0.57 | 1.56 | 2.12 | 2.3 | 0 | 0 | 2.4 | 119 |
| ScalWS fountain | 0.82 | 2.80 | 3.66 | 5.1 | 0 | 0 | 2.1 | 106 |
| ingress-nginx default | 0.71 | 2.24 | 3.09 | 8.6 | 0 | 0 | 3.3 | 377 |
| ingress-nginx tuned | 0.69 | 2.05 | 2.80 | 3.0 | 0 | 0 | 3.8 | 376 |
| Traefik | 0.76 | 3.20 | 6.06 | 6.5 | 0 | 0 | 5.8 | 208 |
| Backend churn ScalWS shared | 0.57 | 1.70 | 3.48 | 3.5 | 0 | 0 | 2.5 | 128 |
| ScalWS fountain | 1.10 | 6.08 | 12.26 | 32.0 | 1 | 0 | 2.1 | 118 |
| ingress-nginx default | 0.88 | 4647.93 | 4948.88 | 5001.6 | 5,979 | 768 | 3.4 | 381 |
| ingress-nginx tuned | 0.86 | 4668.45 | 4946.76 | 5001.5 | 8,512 | 768 | 3.9 | 382 |
| Traefik | 1.06 | 9708.00 | 9941.45 | 9965.3 | 13,690 | 0 | 5.6 | 333 |
| Ingress churn ScalWS shared | 0.58 | 1.60 | 2.59 | 2.0 | 0 | 0 | 2.5 | 133 |
| ScalWS fountain | 1.00 | 11.15 | 13.17 | 17.7 | 0 | 0 | 2.2 | 126 |
| ingress-nginx default | 0.72 | 5.02 | 29.77 | 34.9 | 826 | 0 | 3.4 | 379 |
| ingress-nginx tuned | 0.71 | 7.85 | 33.15 | 34.2 | 981 | 0 | 4.0 | 380 |
| Traefik | 0.76 | 3.48 | 6.32 | 7.4 | 0 | 0 | 5.9 | 298 |
“Over 1 s” counts successful requests whose own round trip took more than a second. In backend churn the p99 of ingress-nginx and Traefik is set by requests that wait for a connect timeout to a pod that is gone (ingress-nginx) or hit the load generator's request timeout (Traefik); with a fixed number of connections a blocked one holds up its queue, so the failed and slow counts are the robust figures.
CPU per request and memory
Controller and backend share the same 16 CPUs, so requests/s measures both together. CPU per request of the controller pod isolates the controller: tuned ingress-nginx uses 1.51× the CPU of ScalWS per request in the HTTP/1.1 small-response workload and 2.38× with a new TLS connection per request. At 19,000 req/s ScalWS used 2.4 cores, tuned ingress-nginx 3.8 and Traefik 5.8.
Controller CPU per request
- HTTP/1.1, small response
- HTTP/1.1, 100 KiB response
- TLS, HTTP/1.1
- TLS, HTTP/2
- TLS, new connection per request
CPU time of the controller pod (all its containers) per request, from the pod cgroup. Lower is better.
| Figure | ScalWS shared | ScalWS fountain | ingress-nginx default | ingress-nginx tuned | Traefik | tuned / ScalWS |
|---|---|---|---|---|---|---|
| CPU µs/req, HTTP/1.1, small response | 168.5 | 168.4 | 210.0 | 254.4 | 330.7 | 1.51× |
| CPU µs/req, HTTP/1.1, 100 KiB response | 341.8 | 349.9 | 1127.4 | 484.1 | 640.8 | 1.42× |
| CPU µs/req, TLS, HTTP/1.1 | 183.1 | 180.8 | 241.5 | 285.7 | 357.8 | 1.56× |
| CPU µs/req, TLS, HTTP/2 | 188.8 | 196.4 | 274.2 | 317.3 | 482.6 | 1.68× |
| CPU µs/req, TLS, new connection per request | 1107.3 | 1120.4 | 2622.5 | 2635.1 | 2521.2 | 2.38× |
| CPU cores at 19,000 req/s | 2.4 | 2.1 | 3.3 | 3.8 | 5.8 | 1.58× |
| Peak RSS MiB, HTTP/1.1, small response | 130 | 113 | 382 | 381 | 350 | 2.93× |
| Peak RSS MiB, HTTP/1.1, 100 KiB response | 327 | 261 | 380 | 380 | 362 | 1.16× |
Peak resident memory is that of the whole pod (for ScalWS: scalwsd plus the scalws-ingress sidecar). ScalWS memory grows with the requests in flight and the size of proxied responses: 327 MiB with 100 KiB responses against 130 MiB with small ones. The chart sets no memory limit by default for that reason.
Accept policy: shared, not fountain
The opt-in fountain policy (ADR-0044) fills event loops in order to save CPU at moderate load. It does that here (0.88× of the CPU at 19,000 req/s), but it packs the keep-alive connections onto the first loops and keeps them there, which costs a proxy latency and throughput. For ingress mode keep the default, shared.
| Figure | ScalWS shared | ScalWS fountain | fountain / shared |
|---|---|---|---|
| h1-small req/s | 64.3k | 59.8k | 0.93× |
| h1-100k req/s | 29.0k | 27.3k | 0.94× |
| tls-h1 req/s | 61.2k | 57.6k | 0.94× |
| tls-h2 req/s | 62.4k | 57.4k | 0.92× |
| tls-newconn req/s | 13.5k | 13.0k | 0.96× |
| latency p99 clean ms | 1.56 | 2.80 | 1.79× |
| churn p99 clean ms | 1.70 | 6.08 | 3.58× |
| config p99 clean ms | 1.60 | 11.15 | 6.97× |
| latency CPU cores | 2.4 | 2.1 | 0.88× |
What ingress-nginx was given
Every controller ran with 16 workers, one replica, no CPU limit and access logs off. ingress-nginx ran twice: with its chart defaults and tuned with the settings an operator would reach for. The tuning doubles its 100 KiB throughput but costs it in the other scenarios, so each scenario is also compared with the best of the three ingress-nginx configurations measured.
| Key | Default | Tuned | Why |
|---|---|---|---|
| proxy-buffer-size | 4k | 64k | with proxy-buffering: off (the default, kept: turning it on was slower in round 1) nginx relays a response in reads and writes of this size |
| keep-alive-requests | 1000 | 10000 | client side: HTTP/1.1 connections are closed and HTTP/2 connections get GOAWAY after this many requests |
| upstream-keepalive-requests | 1000 | 10000 | fewer upstream reconnects |
| upstream-keepalive-connections | 320 | 320 | idle upstream connections per worker; the default, stated explicitly |
| worker-processes | auto | 16 | as round 1 |
| Scenario | ingress-nginx defaults | keep-alive only | tuned | ScalWS shared | vs best ingress-nginx |
|---|---|---|---|---|---|
| HTTP/1.1, small response | 58.2k | 58.6k | 49.9k | 64.3k | 1.10× |
| HTTP/1.1, 100 KiB response | 12.3k | 12.5k | 24.3k | 29.0k | 1.19× |
| TLS, HTTP/1.1 | 51.4k | 52.4k | 45.1k | 61.2k | 1.17× |
| TLS, HTTP/2 | 44.4k | 45.9k | 40.2k | 62.4k | 1.36× |
| TLS, new connection per request | 5.53k | n/a | 5.49k | 13.5k | 2.44× |
“Keep-alive only” is the tuned configuration without the larger proxy buffer; it was not run with a new connection per request. The ratio is ScalWS (shared) against the fastest ingress-nginx configuration of the row, as published.
Limits and where ScalWS loses
ScalWS fountain loses to tuned ingress-nginx
With the opt-in fountain policy ScalWS still has more requests/s, less CPU per request and less memory than tuned ingress-nginx in every scenario, but it is behind on latency in 10 figures:
| Scenario, metric | ScalWS fountain | ingress-nginx tuned | ScalWS shared |
|---|---|---|---|
| HTTP/1.1, small response, p99 | 22.00 | 19.96 | 16.74 |
| HTTP/1.1, 100 KiB response, p99 | 57.83 | 36.12 | 32.64 |
| TLS, HTTP/1.1, p99 | 22.28 | 21.93 | 17.80 |
| TLS, HTTP/2, p99 | 23.00 | 21.26 | 15.18 |
| Fixed rate, quiet, p50 | 0.82 | 0.69 | 0.57 |
| Fixed rate, quiet, p99 | 2.80 | 2.05 | 1.56 |
| Fixed rate, quiet, p99.9 | 3.66 | 2.80 | 2.12 |
| Backend churn, p50 | 1.10 | 0.86 | 0.57 |
| Ingress churn, p50 | 1.00 | 0.71 | 0.58 |
| Ingress churn, p99 | 11.15 | 7.85 | 1.60 |
Read the results with these in mind
- ScalWS shared is not behind ingress-nginx (either configuration) or Traefik in any of the 135 figures compared: requests/s, p50, p99, errors, CPU and peak memory, at full load and at the fixed rate. The narrowest margins are against the best ingress-nginx setting: 1.10× ingress-nginx in the tested HTTP/1.1 small-response workload (keep-alive settings only) and 1.19× in the tested 100 KiB workload (tuned).
- With 100 KiB responses the lead over ingress-nginx defaults (2.35×) is mostly an ingress-nginx default: its proxy buffer of 4k. With 64k the lead is 1.19×.
- One node on one machine. Controller, backend and the load generator share one host; the load generator runs on the other CPU socket. No network card, no kube-proxy or Service path, one controller replica. Multi-node behaviour and real NIC interrupts are not covered.
- Controller and backend share the same 16 CPUs, so a controller that needs less CPU leaves more to the backend. CPU per request is the cleaner efficiency figure.
- In the scenario “TLS, new connection per request” the load generator used 14.2 of its 16 CPUs against ScalWS, close to its limit: treat the ScalWS figure there as a lower bound.
- ingress-nginx is retired upstream (2026). It is still the most widely deployed controller and the one ScalWS's annotation support targets.
- “Tuned” is one operator's reasonable global configuration, not a search for the best ingress-nginx configuration per scenario; ScalWS was not tuned beyond the accept policy, and Traefik ran with its chart defaults (no retries).
- ingress-nginx's HTTP/2 errors (15,560 with defaults, 1,092 tuned, over 3 runs) come from its keep-alive request limit closing connections with GOAWAY. Browsers retry such streams; the load generator counts them as failed.
- Self-signed RSA 2048 certificate, TLS 1.3 in practice. The fixed-rate scenarios ran once per configuration (3 runs each).
Methodology
- Host
- Bare metal: 2x Xeon E5-2650 v2 (8 cores, 16 threads each, no AVX2), 121 GB, Ubuntu 26.04, kernel 7.0.0-38-generic; performance governor, turbo off
- Cluster
- k3s v1.36.5+k3s1, one node (containerd 2.3.4-k3s1.36, flannel CNI, Traefik and ServiceLB disabled)
- CPU split
- k3s, containerd and every pod (controller and backend) on NUMA node 0, CPUs 0-7,16-23; the load generator (oha 1.16.0) on node 1, CPUs 8-15,24-31
- Traffic path
- oha connects from the host straight to the controller pod IP (host, cni0 bridge, veth): no Service, kube-proxy or NodePort, the same path for every controller
- Controllers
- One installed at a time with helm, then removed with its namespace. 16 workers each (ingress-nginx worker processes, Traefik GOMAXPROCS, ScalWS event loops), one replica, no CPU limit, access logs off
- Backend
- nginx:1.27.5-alpine, 4 replicas × 2 workers; a 13-byte and a 100 KiB response; the same Ingress objects for every controller, only ingressClassName differs
- Throughput
- 10 s warm-up, then 3 runs of 30 s with 256 requests in flight (HTTP/2: 64 connections × 4 streams); medians
- Fixed rate
- 3 runs of 60 s at 19,000 req/s with latency correction (no coordinated omission); “clean” percentiles leave out the first second and the seconds in which oha itself stalled
- Versions
- ScalWS 1.0.0-rc.1 at 4823999 (chart deploy/helm/scalws, image bench-4823999); ingress-nginx v1.15.1 (chart 4.15.1); Traefik v3.7.14 (chart 41.7.1)
- Spread
- Largest run-to-run spread (max minus min over the median): 5.9 % for ScalWS shared and the other controllers, 6.3 % for fountain. A second pass in reverse order repeated the throughput medians within 1.9 % (3.3 % for fountain).
Sources
- The full report: every table, the profiles behind the fountain result and the round 1 history.
- The harness: scripts, Helm values per controller and how to run it.
- Ingress controller guide: install, annotations and the differences from ingress-nginx.
History
Round 1 (ScalWS 115f5f2) found three problems in ScalWS: the chart's default memory limit got scalwsd OOM-killed with 100 KiB responses, requests on a reused connection to a pod that was shutting down were not retried, and the shared listener's backlog was too short for a burst of new connections. They were fixed (d14639f), and round 2 (4823999) also lets the generated pools retry twice and only lets the namespace that owns a host add paths to it.
| Run | ScalWS commit | failed requests |
|---|---|---|
| Round 1 | 115f5f2 | 126 |
| After fixes | d14639f | 15 |
| Round 2 | 4823999 | 0 |