Repository navigation
Performance
Titanium targets low-overhead MITM proxying: connection pooling, HTTP/2 multiplexing, and buffer reuse. Numbers below are Release measurements with RpsLoadProbe (and BenchmarkDotNet / Basic example where noted). Publishable tables cite GitHub Actions medians on matched 4 vCPU / 16 GiB runners. Absolute RPS still varies by OS kernel, TLS, and MsQuic packaging — compare within a table, not across Windows vs Linux.
Control arms: nginx (native C reverse-proxy ceiling; Linux is authoritative) and YARP (Yarp.ReverseProxy, managed .NET reverse proxy). Neither can MITM (no CONNECT / forged certs). FiddlerCore is not compared (commercial debugger license; not a throughput peer).
For pooling knobs and certificate first-visit tuning, see Performance and pooling. For the local cool A/B lab, laptop tables, and profiling notes, see Performance Profiling.
- Measurement environment
- Windows — Titanium vs nginx vs YARP
- Linux — Titanium vs nginx vs YARP
-
Heavier reverse workloads
- Windows — heavier reverse GET (64 KiB / 256 KiB)
- Linux — heavier reverse GET (64 KiB / 256 KiB)
- Windows — POST 64 KiB request + 64 KiB response
- Linux — POST 64 KiB request + 64 KiB response
- Windows — lossy / high-RTT (H2 HOL / H3 loss)
- Linux — lossy / high-RTT (H2 HOL / H3 loss)
- Architecture-sensitive
- TLS termination cost (H1 TLS → cleartext origin)
- Other measurements
- Raising limits on large hosts
Both OS use the standard public-repo GitHub-hosted runner class (4 vCPU / 16 GiB / 14 GB SSD). Same harness knobs (workflow_dispatch RPS saturation: warmup 2s / measure 8s; concurrency 8, 16, 32, 64; median of 3 repeats). Every --ramp arm is three OS processes (parent load generator + origin child + proxy child), except origin-direct arms (load gen + origin only). Prefer TWP÷YARP / TWP÷nginx ratios over absolute RPS.
Laptop High-perf / cool-paired Windows numbers live on Performance Profiling — Local Windows lab. Do not mix those absolutes into the tables below.
| OS | Windows Server (GitHub-hosted windows-latest) |
| CPU | 4 logical processors |
| RAM | 16 GiB |
| Runtime | .NET 10.0.x |
| nginx | nginx/Windows 1.31.3 (same-OS only; no QUIC) |
| YARP | Yarp.ReverseProxy 2.3.0 |
| Harness | RpsLoadProbe Release; median of 3 repeats |
| OS | Ubuntu 24.04.x LTS |
| CPU | 4 logical processors (AMD EPYC; runners this pass were 7763 / 9V74) |
| RAM | 16 GiB |
| Runtime | .NET 10.0.11 |
| nginx | nginx/1.31.4 (nginx.org mainline, --with-http_v3_module) |
| YARP | Yarp.ReverseProxy 2.3.0 |
| Harness | RpsLoadProbe Release; median of 3 repeats where noted |
Calibration for the shared 4 vCPU loopback shape: how close client + origin are to saturated before ranking reverse peers. Tiny keep-alive GET. Median of 3 repeats @ 0ff3673c — 32685356597. Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Block A % of origin-HttpClient uses median peak RPS. Blocks B/C use peer÷YARP / ÷nginx on median peak (not % of H1 origin). Memory (RSS) / CPU sample the proxy child plus its full descendant tree (serve-proxy → nginx master → workers); origin-direct samples the origin child. Product matrices below use matched dotnet-httpclient only (not bombardier). H2→H1 Memory after Http2PendingWork + lite wire: Win ~144 MiB (was ~848 MiB), Linux ~227 MiB (was ~626 MiB), RPS still leads YARP.
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-saturation| Arm | Generator | Notes |
|---|---|---|
origin-direct |
dotnet-httpclient |
No proxy — origin ceiling under the same client used for publishable ratios |
origin-direct-bombardier |
bombardier |
External client check (CI installs bombardier) |
bare-reverse-http1 |
dotnet-httpclient |
Thin C# H1 reverse (BareHttp1ReverseProxy) — .NET runtime / loopback ceiling for a three-process reverse hop; not a product peer |
nginx-reverse-http1 / yarp-reverse-http1 / twp-reverse-http1
|
dotnet-httpclient |
Product peers (medals among these three only) |
Windows (windows-latest)
| Arm | Generator | Sustain | Peak | % of origin-HttpClient | Memory (RSS) | CPU avg % |
|---|---|---|---|---|---|---|
| origin-direct | dotnet-httpclient | 61,873 | 61,983 | 100% | 53 MiB | 42.8 |
| origin-direct-bombardier | bombardier | 46,950 | 47,181 | 76.1% | 54 MiB | 25.6 |
| bare-reverse-http1 | dotnet-httpclient | 30,801 | 30,801 | 49.7% | 54 MiB | 45.0 |
| nginx-reverse-http1 | dotnet-httpclient | 19,151 | 19,451 | 31.4% | 120 MiB | 🥇 24.8 |
| yarp-reverse-http1 | dotnet-httpclient | 26,913 | 26,913 | 43.4% | 84 MiB | 48.8 |
| twp-reverse-http1 | dotnet-httpclient | 🥇 31,869 | 31,869 | 51.4% | 🥇 73 MiB | 45.0 |
Linux (ubuntu-latest)
| Arm | Generator | Sustain | Peak | % of origin-HttpClient | Memory (RSS) | CPU avg % |
|---|---|---|---|---|---|---|
| origin-direct | dotnet-httpclient | 97,911 | 97,912 | 100% | 79 MiB | 43.5 |
| origin-direct-bombardier | bombardier | 61,344 | 61,541 | 62.9% | 79 MiB | 37.8 |
| bare-reverse-http1 | dotnet-httpclient | 46,344 | 46,344 | 47.3% | 69 MiB | 46.2 |
| nginx-reverse-http1 | dotnet-httpclient | 🥇 55,817 | 55,817 | 57.0% | 🥇 72 MiB | 🥇 40.8 |
| yarp-reverse-http1 | dotnet-httpclient | 41,821 | 41,821 | 42.7% | 117 MiB | 49.3 |
| twp-reverse-http1 | dotnet-httpclient | 48,510 | 48,510 | 49.5% | 87 MiB | 50.3 |
Reverse peers are about 28–57% of the origin-direct HttpClient peak on this runner class. Prefer the % column over absolute RPS across runs. Bare and origin-direct are controls (not medal peers).
Peer ratios (÷YARP / ÷nginx) on median peak + Memory (RSS) / CPU among TWP / nginx / YARP.
Windows (windows-latest)
| Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | Memory (RSS) | CPU avg % |
|---|---|---|---|---|---|---|---|
| nginx-reverse-http2 | dotnet-httpclient | 15,025 | 15,025 | 0.43× | 1.00× | 137 MiB | 🥇 23.6 |
| yarp-reverse-http2 | dotnet-httpclient | 35,176 | 35,176 | 1.00× | 2.34× | 🥇 90 MiB | 49.0 |
| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 37,842 | 37,842 | 1.08× | 2.52× | 144 MiB | 49.8 |
Linux (ubuntu-latest)
| Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | Memory (RSS) | CPU avg % |
|---|---|---|---|---|---|---|---|
| nginx-reverse-http2 | dotnet-httpclient | 21,348 | 29,587 | 0.66× | 1.00× | 🥇 96 MiB | 🥇 22.8 |
| yarp-reverse-http2 | dotnet-httpclient | 44,626 | 44,626 | 1.00× | 1.51× | 121 MiB | 48.5 |
| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 49,714 | 49,714 | 1.11× | 1.68× | 227 MiB | 51.9 |
Same layout as Block B. Requires QuicListener; nginx only with http_v3_module (Windows nginx has no QUIC).
Windows (windows-latest)
| Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | Memory (RSS) | CPU avg % |
|---|---|---|---|---|---|---|---|
| nginx-reverse-http3-cleartext | dotnet-httpclient | Not possible (no QUIC) | Not possible | — | — | — | — |
| yarp-reverse-http3-cleartext | dotnet-httpclient | 18,857 | 18,857 | 1.00× | — | 🥇 160 MiB | 48.1 |
| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 19,758 | 19,758 | 1.05× | — | 232 MiB | 🥇 42.9 |
Linux (ubuntu-latest)
| Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | Memory (RSS) | CPU avg % |
|---|---|---|---|---|---|---|---|
| nginx-reverse-http3-cleartext | dotnet-httpclient | 0 | 25,054 | 0.90× | 1.00× | 107 MiB | 21.7 |
| yarp-reverse-http3-cleartext | dotnet-httpclient | 27,950 | 27,950 | 1.00× | 1.12× | 🥇 192 MiB | 49.5 |
| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 31,466 | 31,466 | 1.13× | 1.26× | 301 MiB | 52.1 |
How to read the tables
- Mode: Reverse = transparent fixed-forward (may TLS-terminate to a cleartext origin, or re-encrypt to a configured HTTPS/QUIC origin). MITM = both legs are visible in the clear inside TWP — either by decrypting client TLS/QUIC (forged cert / CONNECT) or by accepting an already-cleartext client (explicit HTTP proxy / inspectable transparent reverse) while still speaking plain or TLS to the origin. nginx and YARP cannot do MITM. HTTP/3 has no cleartext client (QUIC always encrypted).
- Sustainable = last concurrency that still met error/latency SLOs. Peak = highest RPS in that ramp.
- 🥇 = best among TWP / nginx / YARP only on that row or saturation block (never vs bare / origin-direct / bombardier). Product matrices and saturation Sustain: highest sustainable RPS. Saturation Memory (RSS) / CPU avg %: lowest value among those three that have sustain > 0 (sustain 0 / Not possible peers are excluded). Omitted when only TWP can run the path (no fair multi-product comparison).
- Not possible = product cannot do that path. Not measured = path exists but no published number yet for that OS.
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-same
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-terminate
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-bridges
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-mitm
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-bodies
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-post
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-lossy
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-tls-cost
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-arch
pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-saturationClient / origin: HTTP version and whether TLS is used (plain = cleartext, TLS = encrypted, QUIC = HTTP/3).
Median of 3 repeats on windows-latest (4 vCPU / 16 GiB). Same/MITM @ bf01825b (32659721937, 32659725022); bridges @ e58490ba (32678787884) — three-process harness, parent-seeded loopback CA. Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. Laptop High-perf / cool-paired numbers stay on the local lab.
Load generators: Reverse inbound H3 arms use dotnet-httpclient (http_version=3.0, RequestVersionExact). nginx/Windows is same-OS only (no QUIC).
| Mode | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|---|
| Reverse | HTTP/1 · plain | HTTP/1 · plain | 🥇 22,723 | 22,723 | 13,956 | 13,956 | 21,958 | 21,958 |
| Reverse | HTTP/1 · plain | HTTP/1 · TLS | 🥇 19,889 | 19,889 | Not possible | Not possible | 19,875 | 19,875 |
| Reverse | HTTP/1 · TLS | HTTP/1 · plain | 🥇 20,009 | 20,009 | 8,978 | 9,195 | 18,702 | 18,702 |
| Reverse | HTTP/1 · TLS | HTTP/2 · TLS | 🥇 25,737 | 25,737 | Not possible | Not possible | 25,279 | 25,279 |
| Reverse | HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 15,710 | 15,710 | Not possible (no QUIC) | Not possible (no QUIC) | 15,448 | 15,448 |
| Reverse | HTTP/2 · plain | HTTP/1 · plain | 🥇 37,860 | 37,860 | Not possible | Not possible | 33,865 | 33,865 |
| Reverse | HTTP/2 · plain | HTTP/2 · plain | 🥇 88,895 | 88,895 | Not possible | Not possible | 65,961 | 65,961 |
| Reverse | HTTP/2 · plain | HTTP/2 · TLS | 🥇 77,703 | 77,703 | Not possible | Not possible | 56,634 | 56,634 |
| Reverse | HTTP/2 · plain | HTTP/3 · QUIC | 🥇 30,689 | 30,689 | Not possible (no QUIC) | Not possible (no QUIC) | 29,310 | 29,310 |
| Reverse | HTTP/2 · TLS | HTTP/1 · plain | 🥇 36,524 | 36,524 | 11,464 | 11,474 | 30,277 | 30,277 |
| Reverse | HTTP/2 · TLS | HTTP/2 · plain | 🥇 83,333 | 83,333 | Not possible | Not possible | 56,026 | 56,026 |
| Reverse | HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 29,415 | 29,415 | Not possible (no QUIC) | Not possible (no QUIC) | 26,078 | 26,078 |
| Reverse | HTTP/3 · QUIC | HTTP/1 · plain | 16,092 | 16,092 | Not possible (no QUIC) | Not possible (no QUIC) | 🥇 16,201 | 16,201 |
| Reverse | HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 24,432 | 24,432 | Not possible (no QUIC) | Not possible (no QUIC) | 21,704 | 21,704 |
| Reverse | HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 21,206 | 21,206 | Not possible (no QUIC) | Not possible (no QUIC) | 16,184 | 16,184 |
| MITM | HTTP/1 · plain | HTTP/1 · plain | 21,505 | 21,505 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · plain | HTTP/1 · TLS | 19,463 | 19,463 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/1 · plain | 19,386 | 19,386 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/2 · TLS | 24,901 | 24,901 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/3 · QUIC | 15,503 | 15,503 | Not possible (no QUIC) | Not possible (no QUIC) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/1 · plain | 31,062 | 31,062 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/2 · plain | 87,152 | 87,152 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/2 · TLS | 76,028 | 76,028 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/3 · QUIC | 30,653 | 30,653 | Not possible (no QUIC) | Not possible (no QUIC) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/1 · plain | 30,187 | 30,187 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/2 · plain | 81,841 | 81,841 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/3 · QUIC | 29,435 | 29,435 | Not possible (no QUIC) | Not possible (no QUIC) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/1 · plain | 12,835 | 12,835 | Not possible (no QUIC) | Not possible (no QUIC) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/2 · TLS | 24,383 | 24,383 | Not possible (no QUIC) | Not possible (no QUIC) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/3 · QUIC | 21,011 | 21,011 | Not possible (no QUIC) | Not possible (no QUIC) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · plain (CONNECT) | HTTP/1 · TLS | 16,815 | 16,815 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/1 · TLS | 17,785 | 17,785 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/2 · TLS | 72,361 | 72,361 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/1 · TLS | 27,020 | 27,020 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/1 · TLS | 11,684 | 11,684 | Not possible (no QUIC) | Not possible (no QUIC) | Not possible (no MITM) | Not possible (no MITM) |
TWP÷YARP H1 plain ≈ 1.03× (22,723 / 21,958); H1 TLS terminate ≈ 1.07× (20,009 / 18,702). Bridges @ e58490ba: h2c→H1 ≈ 1.12×, H3→H2 ≈ 1.13×, H1→H3 ≈ 1.02×, h2c→H3 ≈ 1.05×, H2 TLS→H3 ≈ 1.13×; open Win gap vs YARP (gate ≥1.00×): H3→H1 ≈ 0.993× (16,092 / 16,201). Prefer ratios over absolute RPS on GHA VMs. MITM publishes the same 15 Client×Origin pairs as Reverse (inspectable/decrypt), then dual-crypto extras (CONNECT, TLS↔TLS). nginx/YARP cannot MITM.
Median of 3 repeats on ubuntu-latest (4 vCPU / 16 GiB). Same/MITM @ bf01825b (32659721937, 32659725022); bridges @ e58490ba (32678787884). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Linux nginx is the authoritative nginx baseline. The RPS workflow installs nginx.org mainline (http_v3_module) and libmsquic (QuicListener.IsSupported=true on ubuntu-latest). Prefer ratios over absolute RPS.
TWP÷nginx H1 plain reverse ≈ 0.85 (31,835 / 37,241); TWP÷YARP H1 plain ≈ 1.12× (31,835 / 28,397).
| Mode | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|---|
| Reverse | HTTP/1 · plain | HTTP/1 · plain | 31,835 | 31,835 | 🥇 37,241 | 37,241 | 28,397 | 28,397 |
| Reverse | HTTP/1 · plain | HTTP/1 · TLS | 🥇 23,709 | 23,709 | Not possible | Not possible | 22,090 | 22,090 |
| Reverse | HTTP/1 · TLS | HTTP/1 · plain | 23,478 | 23,478 | 🥇 28,181 | 28,181 | 20,392 | 20,392 |
| Reverse | HTTP/1 · TLS | HTTP/2 · TLS | 🥇 22,809 | 22,809 | Not possible | Not possible | 21,760 | 21,760 |
| Reverse | HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 17,072 | 17,072 | Not possible (no H3 origin) | Not possible (no H3 origin) | 16,441 | 16,441 |
| Reverse | HTTP/2 · plain | HTTP/1 · plain | 🥇 37,593 | 37,593 | Not possible | Not possible | 33,803 | 33,803 |
| Reverse | HTTP/2 · plain | HTTP/2 · plain | 🥇 66,402 | 66,402 | Not possible | Not possible | 48,631 | 48,631 |
| Reverse | HTTP/2 · plain | HTTP/2 · TLS | 🥇 51,321 | 51,321 | Not possible | Not possible | 39,522 | 39,522 |
| Reverse | HTTP/2 · plain | HTTP/3 · QUIC | 🥇 26,318 | 26,318 | Not possible (no H3 origin) | Not possible (no H3 origin) | 25,849 | 25,849 |
| Reverse | HTTP/2 · TLS | HTTP/1 · plain | 🥇 34,662 | 34,662 | 13,291 | 18,780 | 29,276 | 29,276 |
| Reverse | HTTP/2 · TLS | HTTP/2 · plain | 🥇 59,676 | 59,676 | Not possible | Not possible | 39,640 | 39,640 |
| Reverse | HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 25,081 | 25,081 | Not possible (no H3 origin) | Not possible (no H3 origin) | 22,522 | 22,522 |
| Reverse | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 20,973 | 20,973 | 0 | 14,887 | 18,367 | 18,367 |
| Reverse | HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 23,315 | 23,315 | Not possible (no H3→H2) | Not possible (no H3→H2) | 21,148 | 21,148 |
| Reverse | HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 19,361 | 19,361 | Not possible (no H3 origin) | Not possible (no H3 origin) | 15,825 | 15,825 |
| MITM | HTTP/1 · plain | HTTP/1 · plain | 31,659 | 31,659 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · plain | HTTP/1 · TLS | 24,249 | 24,249 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/1 · plain | 23,444 | 23,444 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/2 · TLS | 24,519 | 24,519 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/3 · QUIC | 17,773 | 17,773 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/1 · plain | 38,330 | 38,330 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/2 · plain | 66,340 | 66,340 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/2 · TLS | 51,899 | 51,899 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · plain | HTTP/3 · QUIC | 27,767 | 27,767 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/1 · plain | 35,342 | 35,342 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/2 · plain | 60,463 | 60,463 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/3 · QUIC | 25,733 | 25,733 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/1 · plain | 20,509 | 20,509 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/2 · TLS | 24,229 | 24,229 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/3 · QUIC | 19,674 | 19,674 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · plain (CONNECT) | HTTP/1 · TLS | 18,316 | 18,316 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/1 · TLS | HTTP/1 · TLS | 19,547 | 19,547 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/2 · TLS | 48,957 | 48,957 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/2 · TLS | HTTP/1 · TLS | 27,168 | 27,168 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
| MITM | HTTP/3 · QUIC | HTTP/1 · TLS | 15,903 | 15,903 | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) | Not possible (no MITM) |
On this GHA shape, TWP H1 plain ÷ nginx H1 plain ≈ 0.85 (31,835 / 37,241). H1 TLS terminate ≈ 0.83 (23,478 / 28,181). TWP÷YARP H1 plain ≈ 1.12×; H1 TLS terminate ≈ 1.15×. Bridges @ e58490ba: all published TWP÷YARP ≥1.00× (h2c→H1 ≈ 1.11×, H3→H1 ≈ 1.14×, H3→H2 ≈ 1.10×). Absolute RPS swings by VM; prefer the ratio and median across repeats. MITM publishes the same 15 Client×Origin pairs as Reverse (inspectable/decrypt), then dual-crypto extras (CONNECT, TLS↔TLS). nginx/YARP cannot MITM.
nginx HTTP/3: inbound QUIC terminate → cleartext H1 (nginx-reverse-http3-cleartext) @ e58490ba bridges: sustain 0 (p99/error SLO miss) / peak 14,887. TWP/YARP H3→H1 on this row are from the same bridges pass. nginx still cannot speak HTTP/3 to an origin (no H3 upstream in this conf).
YARP HTTP/3 (this matrix): TWP leads H3→H1 ≈ 1.14× (20,973 / 18,367), H3→H2 ≈ 1.10× (23,315 / 21,148). H1→H2 ≈ 1.05× (22,809 / 21,760). H1→H3 ≈ 1.04× (17,072 / 16,441). h2c→H3 ≈ 1.02×.
Windows vs Linux: both CI envs are 4 vCPU / 16 GiB, but do not compare absolute RPS across OS. Linux nginx leads H1 plain/TLS terminate (TWP second, ahead of YARP). Windows bridges @ e58490ba closed most YARP gaps; H3→H1 remains the open Win YARP-led cell (≈ 0.993×). Cool laptop notes remain on Performance Profiling.
The tables above use ~64 B keep-alive GET on loopback. On Linux H1 reverse, nginx leads; YARP sits near TWP. Heavier bodies, POSTs, TLS handshake cost, and lossy/HOL workloads (below) change the picture. MITM rows remain TWP-only. nginx HTTP/3 is inbound-terminate only (see note above).
For tiny JSON responses (~64 B) on loopback, that ordering is not expected: topology (TLS hop count, terminate vs MITM) dominates; HTTP/2 and HTTP/3 help multiplexing, not single-origin tiny-GET RPS. See the lossy tables below for a workload where protocol design matters.
Separate from the tiny-GET matrix. Same measurement environments. Modes: compare-bodies, compare-post, compare-lossy, compare-tls-cost, compare-arch in RpsLoadProbe. PUT with the same body is the same proxy work as POST; DELETE with no body matches GET — only POST is published. Bodies/POST/lossy stay half-duplex. compare-arch is the slow-consumer / early-response / duplex set. Laptop numbers are on Performance-Profiling; CI medians go in the tables below.
Lossy link = userspace shim (not kernel netem): TCP gets per-buffer delay + occasional whole-connection stalls (honest HOL for multiplexed H2); UDP gets per-datagram delay + drops (QUIC). compare-lossy publishes H1/H2/H3; H3 is where the protocol design is supposed to matter.
Median of 3 repeats on windows-latest @ 8ac422ee. Source: Actions 32631121563 (compare-bodies). Warmup 2s / measure 8s.
| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|---|
| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 11,026 | 12,257 | 1,129 | 1,184 | 9,985 | 11,229 |
| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 10,112 | 10,346 | 1,006 | 1,031 | 8,630 | 9,265 |
| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 5,361 | 6,006 | Not possible (no QUIC) | Not possible (no QUIC) | 4,913 | 4,913 |
| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 3,323 | 3,687 | 274 | 309 | 2,244 | 2,244 |
| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 2,414 | 2,438 | 222 | 222 | 2,302 | 2,642 |
| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 1,495 | 1,569 | Not possible (no QUIC) | Not possible (no QUIC) | 1,371 | 1,410 |
nginx/Windows collapses on large reverse bodies in this harness; treat as same-OS only. H1 TLS 64 KiB ≈ 1.10× YARP; 256 KiB ≈ 1.48×. H2→H1 64 KiB ≈ 1.17×. H3→H1 64 KiB ≈ 1.09×; 256 KiB ≈ 1.09×.
Median of 3 repeats @ 8ac422ee. Source: Actions 32631121563 (compare-bodies). Warmup 2s / measure 8s.
| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|---|
| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 8,457 | 8,457 | 🥇 8,789 | 8,800 | 6,885 | 7,036 |
| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 5,953 | 5,953 | 4,004 | 4,004 | 5,251 | 5,251 |
| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 5,776 | 5,776 | 1,737 | 1,832 | 4,462 | 4,462 |
| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 2,821 | 2,821 | 2,726 | 2,726 | 2,186 | 2,186 |
| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 1,543 | 1,543 | 1,010 | 1,014 | 1,407 | 1,407 |
| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 1,546 | 1,546 | 440 | 458 | 1,299 | 1,325 |
On this GHA pass TWP÷YARP H1 TLS ≈ 1.23× (64 KiB) / 1.29× (256 KiB); H2→H1 ≈ 1.13× / 1.10×; H3→H1 ≈ 1.29× / 1.19×. TWP÷nginx H1 TLS ≈ 0.96 / 1.03. Absolute RPS swings by VM; prefer ratios.
Median of 3 repeats on windows-latest @ 21396a4d. Source: Actions 32608567396 (compare-post).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 🥇 7,594 | 7,819 | 425 | 433 | 5,921 | 5,927 |
| HTTP/2 · TLS | HTTP/1 · plain | 🥇 6,006 | 6,006 | 423 | 444 | 4,880 | 4,880 |
| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 2,772 | 2,816 | Not possible | Not possible | 2,769 | 2,861 |
TWP leads H1 POST (1.28× YARP), H2 POST (1.23× YARP), and H3 POST (~1.00× YARP).
Median of 3 repeats @ 21396a4d. Source: Actions 32608567396 (compare-post).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 🥇 4,415 | 4,415 | 4,277 | 4,292 | 3,406 | 3,406 |
| HTTP/2 · TLS | HTTP/1 · plain | 🥇 3,350 | 3,350 | 2,071 | 2,147 | 2,776 | 2,776 |
| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 2,919 | 2,919 | 799 | 802 | 2,771 | 2,771 |
Linux nginx H1/H2/H3 POST completed (nginx.org mainline). TWP÷YARP H3 POST ≈ 1.05×; TWP÷nginx H3 ≈ 3.65×.
Userspace 5 ms one-way delay + 1% TCP connection stall (H1/H2) or UDP datagram drop (H3); 64 KiB GET. Median of 3 repeats on windows-latest @ ab9c0631. Source: 32666017510 (compare-lossy). H3 on GHA Windows collapses through the userspace UDP shim (sustain ≈ 0); use the laptop lab for the Windows H3 signal.
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 🥇 662 | 662 | 635 | 635 | 🥇 662 | 662 |
| HTTP/2 · TLS | HTTP/1 · plain | 🥇 60 | 87 | 18 | 18 | 17 | 17 |
| HTTP/3 · QUIC | HTTP/1 · plain | Not measured (GHA UDP-shim) | Not measured | Not possible (no QUIC) | Not possible | Not measured (GHA UDP-shim) | Not measured |
H1: TWP÷YARP ≈ 1.00× (662 / 662). H2: TWP÷YARP ≈ 3.53× (60 / 17) with lossy-only MaxConcurrentStreamsPerConnection=8. Laptop Windows (same shim, quic-http3): TWP H3 ≈ 1,572 sustain vs H2 ≈ 14 (~112×). Absolute RPS is low because the shim delays every buffer/datagram — the point is the protocol shape.
Median of 3 repeats @ ab9c0631. Source: 32666017510 (compare-lossy; lossy H3 uses quic-http3).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 1,195 | 1,195 | 🥇 1,207 | 1,207 | 1,192 | 1,192 |
| HTTP/2 · TLS | HTTP/1 · plain | 🥇 319 | 319 | 40 | 40 | 44 | 44 |
| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 321 | 321 | 90 | 90 | 316 | 316 |
Same H1 story (nginx 1st 1,207, TWP 2nd ahead of YARP ≈ 1.00×). H2: TWP leads ≈ 7.3× YARP (319 / 44). H3: TWP leads ≈ 1.02× YARP (321 / 316) after size-gated HEADERS+DATA coalesce for bodies ≥16 KiB (ab9c0631); nginx H3 terminate stays far below — TWP 1st among peers that complete.
compare-arch isolates slow app readers, origin-early response, H2 duplex, and WebSocket echo. See TWP vs YARP IO model. Laptop 1-rep numbers are on Performance-Profiling.
Median of 3 repeats on matched 4 vCPU / 16 GiB runners. Slow/early/duplex rows @ 21396a4d (32608568872); WebSocket remasure @ ab9c0631 (32666019333) (compare-arch). Slow consumer = 256 KiB GET, 16 KiB read + 8 ms sleep. Early response = 64 KiB POST, origin writes after 8 KiB. Duplex H2 = overlapping 64 KiB POST on H2 TLS↔H2 TLS. WebSocket = echo round-trips/sec.
compare-lossy (slow network) is already published above; it is not a slow app reader.
| Scenario | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|---|
| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 256 | 256 | 243 | 243 | 🥇 256 | 256 |
| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 256 | 256 | 248 | 248 | 🥇 256 | 256 |
| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 272 | 272 | Not possible (no QUIC) | Not possible | 🥇 272 | 272 |
| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 11,243 | 12,400 | 584 | 637 | 6,868 | 9,421 |
| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 8,770 | 9,163 | 0 | 622 | 6,867 | 6,867 |
| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 4,041 | 4,256 | Not possible (no QUIC) | Not possible | 3,656 | 3,955 |
| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | 🥇 35 | 1,102 | Not possible | Not possible | 0 | 2,235 |
| Duplex (WebSocket / extended CONNECT) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 23,951 | 23,951 | 13,082 | 13,082 | 21,032 | 21,032 |
| Scenario | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|---|---|
| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | 447 | 447 | 🥇 472 | 472 | 419 | 419 |
| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | 472 | 472 | 🥇 479 | 479 | 472 | 472 |
| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 473 | 473 | 120 | 427 | 472 | 472 |
| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 4,334 | 4,334 | 4,305 | 4,305 | 3,336 | 3,336 |
| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 3,695 | 3,695 | 0 | 2,128 | 2,402 | 2,555 |
| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 2,861 | 2,861 | 0 | 784 | 2,227 | 2,227 |
| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | 🥇 16 | 210 | Not possible | Not possible | 13 | 1,884 |
| Duplex (WebSocket / extended CONNECT) | HTTP/1 · TLS | HTTP/1 · plain | 32,850 | 32,850 | 🥇 34,594 | 34,594 | 28,450 | 28,450 |
Slow consumer is sleep-bound; H1/H2/H3 sit in the same band once bodies stream. Early-response H3: TWP leads on both OS. Duplex H2: TWP holds a higher sustain than YARP on this pass (YARP peaks higher). WebSocket @ ab9c0631 arch: TWP leads on Windows ≈ 1.14× YARP (23,951 / 21,032); Linux nginx leads.
Isolates keep-alive tiny GET vs new connection per request (handshake-dominated) vs keep-alive 256 KiB. Product comparison uses RPS and end-to-end latency; TWP can also capture ClientTlsTiming when TWP_RPS_CAPTURE_TLS=1 (child process) — nginx/YARP have no equivalent hook.
Median of 3 repeats on windows-latest @ 13059143. Source: Actions 32625349927 (compare-tls-cost). Absolute RPS on GHA swings hard; prefer TWP÷YARP.
| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|
| Keep-alive · tiny GET | 🥇 23,320 | 23,320 | 11,801 | 12,719 | 21,827 | 21,827 |
| New-connection · tiny GET | 🥇 708 | 708 | 235 | 238 | 701 | 701 |
| Keep-alive · 256 KiB GET | 🥇 2,840 | 2,891 | 220 | 236 | 2,582 | 2,594 |
Median of 3 repeats @ 13059143. Source: Actions 32625349927 (compare-tls-cost).
| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |
|---|---|---|---|---|---|---|
| Keep-alive · tiny GET | 24,241 | 24,241 | 🥇 29,229 | 29,229 | 21,058 | 21,058 |
| New-connection · tiny GET | 999 | 999 | 🥇 1,023 | 1,024 | 986 | 986 |
| Keep-alive · 256 KiB GET | 🥇 2,776 | 2,776 | 2,685 | 2,685 | 2,194 | 2,194 |
All three workloads are >1.00× YARP on both OS. nginx leads Linux keep-alive tiny and Linux new-connection; TWP is second, YARP third.
| What | Result |
|---|---|
| HTTPS TTFB vs direct (median, 14 hosts) | Cold ≈ parity (−1 ms); warm −25 ms (proxy faster) |
| HTTP/1 loopback GET (no body intercept) | ~186 µs, ~17.5 KB allocated / request |
| Basic example footprint (Release, after load) | ~74 MB working set · ~24–29 MB private bytes |
dotnet run -c Release --project benchmarks/Titanium.Web.Proxy.Benchmarks -- --filter '*Throughput*'| Benchmark | Setup | Mean | Allocated / op |
|---|---|---|---|
| HTTP/1 GET through proxy | Passthrough | 186 µs | 17.5 KB |
| HTTP/2 multiplexed GETs | 10 concurrent streams | 3.0 ms / batch | ~14 KB / request |
There is no artificial upper clamp on server defaults. Per-endpoint overrides:
| Knob | Scope | Default | Override |
|---|---|---|---|
ProxyServer.MaxCachedConnections |
process, per upstream host | 128 | any ≥ 1 |
ProxyEndPoint.MaxCachedConnections |
endpoint → pool depth for that EP’s sessions | null (use server) | e.g. 256 on reverse EP |
ProxyEndPoint.MaxConcurrentClients |
endpoint admission | null (off) | any ≥ 1 |
ResourceLimits.MaxConcurrentStreamsPerConnection |
H2 streams | 256 | ProxyResourceLimits.Create(...) |
TransparentQuicProxyEndPoint.MaxInboundBidirectionalStreams |
H3 | 100 (probe uses 256) | property on EP |
ForwardCleartext |
transparent TLS terminate | false |
true + decrypt |
proxy.MaxCachedConnections = 512;
proxy.ResourceLimits = ProxyResourceLimits.Create(
/* … */,
maxConcurrentStreamsPerConnection: 1000,
maxCachedConnectionsPerHost: 512,
/* … */);
var ep = new TransparentProxyEndPoint(IPAddress.Any, 443, decryptSsl: true)
{
ForwardHost = "127.0.0.1",
ForwardPort = 8080,
ForwardCleartext = true,
MaxCachedConnections = 256,
GenericCertificateName = "example.com"
};
ep.BeforeSslAuthenticate += (_, a) =>
{
a.UpstreamHttpProtocol = UpstreamHttpProtocol.Http11;
a.AllowHttpProtocolTranslation = true; // HTTP/2 client → HTTP/1 origin
return Task.CompletedTask;
};