-
Notifications
You must be signed in to change notification settings - Fork 656
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,642 | 36,995 | 25,466 | 25,466 | TWP |
| Reverse | HTTP/1 · TLS | HTTP/1 · plain | 29,097 | 29,097 | 16,584 | 16,584 | TWP |
| MITM | HTTP/1 · TLS | HTTP/1 · TLS | 29,906 | 29,906 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/1 · plain | 9,823 | 9,823 | 15,859 | 15,859 | nginx |
| MITM | HTTP/2 · TLS | HTTP/1 · TLS | 6,135 | 6,758 | Not possible (no MITM) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/2 · TLS | 6,972 | 6,972 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/2 · plain | 5,884 | 5,884 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/1 · plain | 10,337 | 10,631 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · plain | 6,811 | 7,320 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · TLS | 7,197 | 7,197 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/3 · QUIC | 6,438 | 6,610 | Not possible (no QUIC) | Not possible | |
| Reverse | HTTP/3 · QUIC | HTTP/1 · plain | 12,090 | 12,090 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/1 · TLS | 7,078 | 7,078 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/2 · TLS | 3,392 | 3,392 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/3 · QUIC | 9,438 | 10,138 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/2 · TLS | 8,216 | 8,216 | Not possible | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/3 · QUIC | 11,222 | 11,222 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/3 · QUIC | 4,395 | 4,713 | Not possible (no QUIC) | Not possible |
Windows sources: local compare-same (rps-ramp-20260816-175237), compare-terminate (rps-ramp-20260816-175535), compare-bridges (rps-ramp-20260816-175720), compare-mitm (rps-ramp-20260816-180015); warmup 1s; measure 3s; concurrency 8–64. All published TWP arms 0% error. Re-run after reverting keep-alive SessionEventArgs reuse (header intern retained).
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) — nginx leads sustain on this laptop run. Absolute RPS swings with background load; treat as same-OS only.
Median of 3 repeats from Actions runs 31962291345 (compare-same), 31962292553 (compare-terminate), 31962296498 (compare-mitm), 31962293943 (compare-bridges). Prior ceiling control: 31954146336 (compare-ceiling). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. All published TWP arms 0% error. 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.64×): 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 | 44,515 | 44,515 | 69,889 | 69,889 | nginx |
| Reverse | HTTP/1 · TLS | HTTP/1 · plain | 34,367 | 34,367 | 53,459 | 53,459 | nginx |
| MITM | HTTP/1 · TLS | HTTP/1 · TLS | 15,906 | 15,906 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/1 · plain | 11,291 | 11,291 | 13,467 | 18,959 | nginx |
| MITM | HTTP/2 · TLS | HTTP/1 · TLS | 10,920 | 10,964 | Not possible (no MITM) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/2 · TLS | 10,470 | 10,470 | Not possible (no MITM) | Not possible | |
| Reverse | HTTP/2 · TLS | HTTP/2 · plain | 6,152 | 6,159 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/1 · plain | 16,133 | 16,237 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · plain | 14,431 | 14,441 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/2 · TLS | 11,795 | 11,795 | Not possible | Not possible | |
| Reverse | HTTP/2 · plain | HTTP/3 · QUIC | 11,928 | 11,928 | Not possible (no QUIC) | Not possible | |
| Reverse | HTTP/3 · QUIC | HTTP/1 · plain | 13,196 | 13,196 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/1 · TLS | 11,644 | 11,644 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/2 · TLS | 2,167 | 6,744 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/3 · QUIC | HTTP/3 · QUIC | 11,589 | 11,589 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/2 · TLS | 11,078 | 11,078 | Not possible | Not possible | |
| MITM | HTTP/1 · TLS | HTTP/3 · QUIC | 13,320 | 13,320 | Not possible (no QUIC) | Not possible | |
| MITM | HTTP/2 · TLS | HTTP/3 · QUIC | 8,238 | 8,238 | Not possible (no QUIC) | Not possible |
On this GHA shape, TWP H1 plain ÷ nginx H1 plain ≈ 0.64 (44,515 / 69,889). H1 TLS terminate ≈ 0.64 (34,367 / 53,459). 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-160548).
| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|---|
| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 10,473 | 10,692 | 926 | 1,016 | TWP |
| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 3,600 | 3,656 | 877 | 941 | TWP |
| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 4,567 | 4,567 | Not possible (no QUIC) | Not possible | |
| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 3,246 | 3,266 | 217 | 254 | TWP |
| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 1,032 | 1,048 | 169 | 205 | TWP |
| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 1,048 | 1,145 | 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 31958194269 (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 | 6,565 | 6,565 | 8,375 | 8,375 | nginx |
| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 3,342 | 3,408 | 3,498 | 3,499 | nginx |
| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 3,631 | 3,631 | Not possible (no QUIC) | Not possible | |
| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 2,154 | 2,154 | 2,728 | 2,728 | nginx |
| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 1,015 | 1,015 | 0 | 4 | TWP |
| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 1,117 | 1,117 | Not possible (no QUIC) | Not possible |
On Linux H1 TLS, TWP÷nginx ≈ 0.78 at 64 KiB and ≈ 0.79 at 256 KiB — better than the tiny-GET ≈0.64 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-160844).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 5,070 | 5,070 | 210 | 212 | TWP |
| HTTP/2 · TLS | HTTP/1 · plain | 88 | 124 | 294 | 294 | 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 31958195358 (compare-post).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 3,942 | 3,942 | 0 | 0 | TWP |
| HTTP/2 · TLS | HTTP/1 · plain | 120 | 242 | 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-161416).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 576 | 576 | 633 | 633 | nginx |
| HTTP/2 · TLS | HTTP/1 · plain | 15 | 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 31958196755 (compare-lossy).
| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner |
|---|---|---|---|---|---|---|
| HTTP/1 · TLS | HTTP/1 · plain | 1,122 | 1,122 | 1,210 | 1,210 | nginx |
| HTTP/2 · TLS | HTTP/1 · plain | 44 | 46 | 44 | 45 | (tie) |
Same story as Windows: H1 stays usable; H2 falls to tens of RPS for both products. TWP and nginx are comparable 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-161528).
| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner | TWP÷nginx |
|---|---|---|---|---|---|---|
| Keep-alive · tiny GET | 32,224 | 32,224 | 14,228 | 14,994 | TWP | 2.26 |
| New-connection · tiny GET | 899 | 899 | 663 | 704 | TWP | 1.36 |
| Keep-alive · 256 KiB GET | 2,842 | 2,956 | 202 | 252 | TWP | 14.1 |
Median of 3 repeats. Source: Actions 31958198072 (compare-tls-cost).
| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | Winner | TWP÷nginx |
|---|---|---|---|---|---|---|
| Keep-alive · tiny GET | 23,040 | 23,040 | 35,619 | 35,619 | nginx | 0.65 |
| New-connection · tiny GET | 1,236 | 1,239 | 1,039 | 1,039 | TWP | 1.19 |
| Keep-alive · 256 KiB GET | 2,232 | 2,232 | 2,798 | 2,798 | nginx | 0.80 |
Verdict (Linux, authoritative nginx): On keep-alive tiny terminate, TWP is still ~0.65× nginx (same story as the main table). 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 improves to ~0.80× vs tiny-GET’s ~0.65–0.71×, but 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;
};