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). Absolute RPS varies by hardware, OS, and background load — compare within a table, not across Windows vs Linux.
For pooling knobs and certificate first-visit tuning, see Performance and pooling.
| OS | Windows 11 (10.0.26200) |
| CPU | 11th Gen Intel Core i7-1185G7 @ 3.00 GHz (8 logical processors) |
| RAM | 31.8 GiB |
| Runtime | .NET 10.0.10 |
| nginx | nginx/Windows 1.31.3 |
| Harness | RpsLoadProbe Release; arms run sequentially |
| OS | Ubuntu 24.04.4 LTS |
| CPU | AMD EPYC 7763 (4 logical processors on the VM) |
| RAM | 15.6 GiB |
| Runtime | .NET 10.0.11 |
| nginx | nginx/1.24.0 (Ubuntu) |
| Harness | RpsLoadProbe Release; median of 3 repeats where noted |
How to read the tables
- Mode: Reverse = transparent fixed-forward (may TLS-terminate to a cleartext origin). MITM = proxy decrypts the client crypto and speaks TLS/QUIC to the origin (or explicit decrypt proxy), so both legs are visible in the clear inside TWP. nginx cannot do MITM.
- Sustainable = last concurrency that still met error/latency SLOs. Peak = highest RPS in that ramp.
- Winner = higher sustainable RPS when both TWP and nginx have a number. Left blank when nginx cannot run that path (no fair 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-costClient / origin: HTTP version and whether TLS is used (plain = cleartext, TLS = encrypted, QUIC = HTTP/3).
| Mode | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|---|
| Reverse | HTTP/1 · plain | HTTP/1 · plain | 34,838 | 34,838 | 12,628 | 18,537 | TWP |
| Reverse | HTTP/1 · TLS | HTTP/1 · plain | 32,650 | 32,650 | 9,094 | 13,761 | TWP |
| MITM | HTTP/1 · TLS | HTTP/1 · TLS | 28,073 | 28,073 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/1 · plain | 8,139 | 10,327 | 3,776 | 11,301 | TWP |
| MITM | HTTP/2 · TLS | HTTP/1 · TLS | 6,383 | 7,040 | Not possible (no MITM) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/2 · TLS | 6,263 | 6,263 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/2 · plain | 4,818 | 4,818 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/1 · plain | 9,552 | 9,552 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · plain | 5,031 | 6,354 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · TLS | 4,568 | 4,568 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/3 · QUIC | 6,105 | 6,105 | Not possible (no QUIC) | Not possible | |
| Reverse | HTTP/3 · QUIC | HTTP/1 · plain | 7,753 | 9,039 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/1 · TLS | 6,433 | 6,433 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/2 · TLS | 3,489 | 3,489 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/3 · QUIC | 5,357 | 7,283 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/2 · TLS | 7,582 | 8,092 | Not possible | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/3 · QUIC | 13,149 | 13,149 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/3 · QUIC | 4,669 | 4,669 | Not possible (no QUIC) | Not possible |
Windows sources: local compare-same (rps-ramp-20260816-184254), compare-terminate (rps-ramp-20260816-184551), compare-bridges (rps-ramp-20260816-184737), compare-mitm (rps-ramp-20260816-185035); warmup 1s; measure 3s; concurrency 8–64. Re-run after disabling in-process TWP logging in RpsLoadProbe (Logging.Enabled = false). Absolute RPS on this laptop swings with sequential-arm heat; nginx/Windows sustain is especially noisy.
nginx/Windows is a limited port. Use it for same-OS comparison only — not as the industry nginx baseline.
H2 TLS → H1 plain on Windows: fair terminate (http2-cleartext vs nginx-reverse-http2) — TWP leads sustain on this laptop run (nginx peak is still higher). Absolute RPS swings with background load; treat as same-OS only.
Median of 3 repeats from Actions runs 31965401029 (compare-same), 31965402044 (compare-terminate), 31965404106 (compare-mitm), 31965403038 (compare-bridges). Prior ceiling control: 31954146336 (compare-ceiling). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Re-run after disabling in-process TWP logging in RpsLoadProbe. Linux nginx is the authoritative nginx baseline. The RPS workflow installs libmsquic (QuicListener.IsSupported=true on ubuntu-latest).
Why nginx still leads on H1 plain reverse (~0.69×): fair harness (split processes, same Kestrel origin). Absolute RPS swings by GHA VM; prefer the ratio. A prior bare C# HTTP/1 reverse on the same job shape was ~0.81× nginx (compare-ceiling) — most of the gap is managed runtime vs nginx’s thin C proxy_pass.
| Mode | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|---|
| Reverse | HTTP/1 · plain | HTTP/1 · plain | 25,640 | 25,640 | 37,288 | 37,288 | nginx |
| Reverse | HTTP/1 · TLS | HTTP/1 · plain | 18,609 | 18,609 | 25,152 | 26,254 | nginx |
| MITM | HTTP/1 · TLS | HTTP/1 · TLS | 15,834 | 15,834 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/1 · plain | 11,715 | 11,715 | 13,110 | 18,296 | nginx |
| MITM | HTTP/2 · TLS | HTTP/1 · TLS | 10,787 | 10,787 | Not possible (no MITM) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/2 · TLS | 5,132 | 5,186 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/2 · plain | 6,128 | 6,196 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/1 · plain | 16,025 | 16,025 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · plain | 7,937 | 7,942 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · TLS | 5,676 | 5,677 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/3 · QUIC | 11,492 | 11,492 | Not possible (no QUIC) | Not possible | |
| Reverse | HTTP/3 · QUIC | HTTP/1 · plain | 12,789 | 12,789 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/1 · TLS | 12,076 | 12,076 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/2 · TLS | 2,187 | 6,265 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/3 · QUIC | 10,063 | 10,063 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/2 · TLS | 11,369 | 11,369 | Not possible | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/3 · QUIC | 12,377 | 12,377 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/3 · QUIC | 7,989 | 7,989 | Not possible (no QUIC) | Not possible |
On this GHA shape, TWP H1 plain ÷ nginx H1 plain ≈ 0.69 (25,640 / 37,288). H1 TLS terminate ≈ 0.74 (18,609 / 25,152). Absolute RPS swings by VM; prefer the ratio and median across repeats.
The tables above use ~64 B keep-alive GET on loopback. That is a thin reverse proxy_pass workload: nginx’s C path wins on Linux, and TWP still pays for a full session pipeline per request. “Comparable” on reverse only shows up when the work gets heavier — larger bodies, mutating methods, TLS handshake cost, or delay/loss that exposes HTTP/2 head-of-line blocking. Tiny JSON is the wrong target if the question is whether TWP can keep up with nginx as a reverse proxy under real traffic.
nginx still cannot MITM or speak QUIC in this harness; those paths remain TWP-only.
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 in RpsLoadProbe. PUT with the same body is the same proxy work as POST; DELETE with no body matches GET — only POST is published.
Lossy link = userspace shim (not kernel netem): TCP gets per-buffer delay + occasional whole-connection stalls (honest HOL for multiplexed H2); UDP datagram drop exists in the harness but H3 lossy arms are not published yet (MsQuic + multi-connection load hangs through the shim).
Warmup 1s / measure 3s; concurrency 8–64. Source: local compare-bodies (rps-ramp-20260816-185316).
| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|---|
| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 7,211 | 7,211 | 813 | 862 | TWP |
| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 2,562 | 2,562 | 793 | 793 | TWP |
| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 3,010 | 3,010 | Not possible (no QUIC) | Not possible | |
| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 2,239 | 2,239 | 226 | 228 | TWP |
| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 743 | 743 | 153 | 153 | TWP |
| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 777 | 924 | Not possible (no QUIC) | Not possible |
nginx/Windows collapses on large reverse bodies in this harness; treat as same-OS only.
Median of 3 repeats. Source: Actions 31965405235 (compare-bodies). Warmup 2s / measure 8s.
| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|---|
| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 7,045 | 7,045 | 8,715 | 8,867 | nginx |
| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 3,944 | 4,054 | 3,950 | 3,950 | nginx |
| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 3,595 | 3,595 | Not possible (no QUIC) | Not possible | |
| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 2,194 | 2,194 | 2,742 | 2,742 | nginx |
| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 1,108 | 1,108 | 0 | 4 | TWP |
| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 1,100 | 1,100 | Not possible (no QUIC) | Not possible |
On Linux H1 TLS, TWP÷nginx ≈ 0.81 at 64 KiB and ≈ 0.80 at 256 KiB — better than the tiny-GET ≈0.69 ratio, but nginx still leads sustain when both stay healthy. nginx H2 at 256 KiB failed this harness (~100% errors); TWP H2/H3 completed.
Source: local compare-post (rps-ramp-20260816-185615).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 4,212 | 4,212 | 213 | 239 | TWP |
| HTTP/2 · TLS | HTTP/1 · plain | 104 | 104 | 278 | 278 | nginx |
| HTTP/3 · QUIC | HTTP/1 · plain | 0 | 0 | Not possible | Not possible |
H3 POST reverse-terminate hit stream aborts in the probe (not published as a capability claim).
Median of 3 repeats. Source: Actions 31965406241 (compare-post).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 3,950 | 3,950 | 0 | 0 | TWP |
| HTTP/2 · TLS | HTTP/1 · plain | 114 | 276 | 0 | 0 | TWP |
| HTTP/3 · QUIC | HTTP/1 · plain | 0 | 0 | Not possible | Not possible |
Linux nginx returned 100% errors on 64 KiB POST in this harness (Windows nginx did complete). Prefer TWP H1 POST as a working reverse path; do not read the nginx zero as a fair peak contest until the nginx POST arm is healthy on Ubuntu.
Userspace 5 ms one-way delay + 1% connection stall; 64 KiB GET. Source: local compare-lossy (rps-ramp-20260816-185745).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 557 | 557 | 625 | 625 | nginx |
| HTTP/2 · TLS | HTTP/1 · plain | 14 | 15 | 11 | 13 | TWP |
H1 scales with concurrency; H2 collapses under connection stalls (HOL). Absolute RPS is low because the shim delays every buffer — the point is the protocol shape, not competing with the tiny-GET table.
Median of 3 repeats. Source: Actions 31965407240 (compare-lossy).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 1,139 | 1,139 | 1,208 | 1,208 | nginx |
| HTTP/2 · TLS | HTTP/1 · plain | 40 | 46 | 44 | 44 | nginx |
Same story as Windows: H1 stays usable; H2 falls to tens of RPS for both products. TWP and nginx stay in the same band here; tiny-GET H1 leadership does not carry over.
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 has no equivalent hook.
Source: local compare-tls-cost (rps-ramp-20260816-185857).
| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner | TWP÷nginx |
|---|---|---|---|---|---|---|
| Keep-alive · tiny GET | 21,297 | 29,497 | 12,644 | 12,644 | TWP | 1.68 |
| New-connection · tiny GET | 660 | 660 | 592 | 592 | TWP | 1.11 |
| Keep-alive · 256 KiB GET | 2,539 | 2,539 | 221 | 236 | TWP | 11.5 |
Median of 3 repeats. Source: Actions 31965408368 (compare-tls-cost).
| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner | TWP÷nginx |
|---|---|---|---|---|---|---|
| Keep-alive · tiny GET | 22,831 | 22,873 | 31,954 | 31,954 | nginx | 0.71 |
| New-connection · tiny GET | 1,218 | 1,219 | 1,023 | 1,028 | TWP | 1.19 |
| Keep-alive · 256 KiB GET | 2,222 | 2,222 | 2,774 | 2,774 | nginx | 0.80 |
Verdict (Linux, authoritative nginx): On keep-alive tiny terminate, TWP is ~0.71× nginx (same story as the main table’s ~0.69–0.74). On new-connection terminate, TWP is ahead (~1.19×) — handshake-dominated work is in the same league and can favor TWP. With 256 KiB bodies, the ratio is ~0.80×; nginx still leads sustain when both are healthy.
| 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;
};