Skip to content
github-actions[bot] edited this page Aug 20, 2026 · 210 revisions

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.

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 methodology behind these numbers — the harness, noise control, and the profiling techniques used to find each hotspot — see Performance Profiling.

Measurement environment

Windows (developer laptop)

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
YARP Yarp.ReverseProxy 2.3.0
Harness RpsLoadProbe Release; arms run sequentially

Linux (GitHub-hosted ubuntu-latest)

OS Ubuntu 24.04.4 LTS
CPU AMD EPYC (4 logical processors on the VM; runners this pass were 7763 / 9V74)
RAM 15.6 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

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 = 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 and YARP cannot do MITM.
  • Sustainable = last concurrency that still met error/latency SLOs. Peak = highest RPS in that ramp.
  • Winner = highest sustainable RPS among products that have a number on that row. Left blank 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

Windows — Titanium vs nginx vs YARP

Client / 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 YARP sustain YARP peak Winner
Reverse HTTP/1 · plain HTTP/1 · plain 31,121 31,857 15,196 18,806 38,136 38,136 YARP
Reverse HTTP/1 · TLS HTTP/1 · plain 27,762 27,983 10,252 13,741 32,255 32,255 YARP
Reverse HTTP/1 · TLS HTTP/2 · TLS 28,526 28,526 Not possible Not possible 40,512 40,512 YARP
Reverse HTTP/1 · TLS HTTP/3 · QUIC 25,125 25,125 Not possible (no QUIC) Not possible 27,596 27,596 YARP
Reverse HTTP/2 · plain HTTP/1 · plain 52,183 52,183 Not possible Not possible 52,543 52,543 YARP
Reverse HTTP/2 · plain HTTP/2 · plain 100,568 100,568 Not possible Not possible 86,021 86,021 TWP
Reverse HTTP/2 · plain HTTP/2 · TLS 88,006 88,006 Not possible Not possible 84,634 84,634 TWP
Reverse HTTP/2 · plain HTTP/3 · QUIC 40,075 40,075 Not possible (no QUIC) Not possible 42,235 42,235 YARP
Reverse HTTP/2 · TLS HTTP/1 · plain 49,548 49,548 15,793 15,793 49,072 49,072 TWP
Reverse HTTP/2 · TLS HTTP/2 · plain 94,238 94,238 Not possible Not possible 81,266 81,266 TWP
Reverse HTTP/2 · TLS HTTP/3 · QUIC 34,506 34,506 Not possible (no QUIC) Not possible 35,388 35,388 YARP
Reverse HTTP/3 · QUIC HTTP/1 · plain 19,517 20,320 Not possible (no QUIC) Not possible 28,145 28,145 YARP
Reverse HTTP/3 · QUIC HTTP/2 · TLS 32,883 32,883 Not possible (no QUIC) Not possible 38,892 38,892 YARP
Reverse HTTP/3 · QUIC HTTP/3 · QUIC 24,097 24,097 Not possible (no QUIC) Not possible 25,463 25,463 YARP
MITM HTTP/1 · TLS HTTP/1 · TLS 33,691 33,691 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/2 · TLS HTTP/1 · TLS 48,925 48,925 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/2 · TLS HTTP/2 · TLS 99,545 99,545 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/3 · QUIC HTTP/1 · TLS 21,811 21,811 Not possible (no QUIC) Not possible Not possible (no MITM) Not possible
MITM HTTP/3 · QUIC HTTP/2 · TLS 36,473 36,473 Not possible (no QUIC) Not possible Not possible (no MITM) Not possible
MITM HTTP/3 · QUIC HTTP/3 · QUIC 38,700 38,700 Not possible (no QUIC) Not possible Not possible (no MITM) Not possible

Windows reverse tiny-GET: 2026-08-20 High-perf, Linux-matched harness (warmup 2s / measure 8s; concurrency 8, 16, 32, 64; median of 3 repeats except H2 TLS→H3 and H3→H1/H2, which have 2). CSVs under tools/RpsLoadProbe/results/windows-20260820/ (compare-same, compare-bridges). MITM and heavier reverse: 1-repeat follow-up under windows-20260820-quick/. Absolute RPS swings with sequential-arm heat; prefer TWP÷YARP ratios.

Load generators: Reverse inbound H3 arms use dotnet-httpclient (http_version=3.0, RequestVersionExact) after dual-listen reverse H3. MITM H3→H2 / H3→H3 reuse dual-listen transparent reverse (reverse-http3-to-http2, reverse-http3). Older UDP-only quic-http3 MITM H3→H1 numbers are not comparable to HttpClient reverse twins.

Matched HttpClient TWP÷YARP (this pass): H1 plain ≈ 0.82 (31,121 / 38,136). H1 TLS terminate ≈ 0.86 (27,762 / 32,255). H1→H2 ≈ 0.70 (28,526 / 40,512). H1→H3 ≈ 0.91 (25,125 / 27,596). h2c→h2c ≈ 1.17× (100,568 / 86,021; TWP leads). h2c→H2 TLS ≈ 1.04× (88,006 / 84,634; TWP leads). H2 TLS→h2c ≈ 1.16× (94,238 / 81,266; TWP leads). H2 TLS→H1 ≈ 1.01× (49,548 / 49,072; TWP leads). h2c→H1 ≈ 0.99. h2c→H3 ≈ 0.95. H2 TLS→H3 ≈ 0.98. H3→H3 ≈ 0.95 (24,097 / 25,463). H3→H2 ≈ 0.85 (32,883 / 38,892). H3→H1 ≈ 0.69 (19,517 / 28,145).

MITM÷TWP reverse pass-through twin (1-rep quick): Transparent H1 dual-TLS (reverse-http1-mitm) ÷ H1 TLS terminate ≈ 1.21 (33,691 / 27,762). H2→H1 MITM ÷ H2→H1 cleartext ≈ 0.99 (48,925 / 49,548). H3→H1 MITM ÷ H3→H1 cleartext ≈ 1.12 (21,811 / 19,517). Explicit CONNECT https-mitm ≈ 31,294 sustain / 35,794 peak (CONNECT tax; not the fair twin).

Why H3 absolute RPS ≪ H2 on this box: tiny-GET loopback is not where H3 wins (see below). YARP H3→H3 also tops out ~25k while TWP H2 same-protocol reaches ~90–100k — MsQuic + dual QUIC hops dominate, not TWP architecture. On this sequential Windows pass TWP H3→H3 is 0.95× YARP (Linux still has TWP leading that row).

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 — TWP leads sustain (~1.01× YARP) in the current table. Absolute RPS swings with background load; treat as same-OS only.

Linux — Titanium vs nginx vs YARP

Median of 3 repeats from Actions runs 32335537070 (compare-same), 32335538634 (compare-terminate), 32334393173 (compare-bridges), 32334395907 (compare-mitm). nginx HTTP/3 tiny-GET from 32337905168. 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).

TWP÷nginx H1 plain reverse ≈ 0.67; TWP÷YARP H1 plain ≈ 0.94. Prefer ratios over absolute RPS on GHA VMs.

Mode Client Origin TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
Reverse HTTP/1 · plain HTTP/1 · plain 26,808 26,808 39,886 39,886 28,662 28,662 nginx
Reverse HTTP/1 · TLS HTTP/1 · plain 22,592 22,592 32,234 32,234 25,863 25,863 nginx
Reverse HTTP/1 · TLS HTTP/2 · TLS 18,401 18,401 Not possible Not possible 23,194 23,194 YARP
Reverse HTTP/1 · TLS HTTP/3 · QUIC 17,030 17,030 Not possible (no H3 origin) Not possible 17,630 17,630 YARP
Reverse HTTP/2 · plain HTTP/1 · plain 40,459 40,459 Not possible Not possible 40,441 40,441 TWP
Reverse HTTP/2 · plain HTTP/2 · plain 66,345 66,345 Not possible Not possible 49,812 49,812 TWP
Reverse HTTP/2 · plain HTTP/2 · TLS 54,696 54,696 Not possible Not possible 41,854 41,854 TWP
Reverse HTTP/2 · plain HTTP/3 · QUIC 29,621 29,621 Not possible (no H3 origin) Not possible 27,928 27,928 TWP
Reverse HTTP/2 · TLS HTTP/1 · plain 38,702 38,702 16,257 22,708 34,189 34,189 TWP
Reverse HTTP/2 · TLS HTTP/2 · plain 59,721 59,721 Not possible Not possible 40,008 40,008 TWP
Reverse HTTP/2 · TLS HTTP/3 · QUIC 28,063 28,063 Not possible (no H3 origin) Not possible 23,698 23,698 TWP
Reverse HTTP/3 · QUIC HTTP/1 · plain 22,567 22,567 0 18,996 22,002 22,002 TWP
Reverse HTTP/3 · QUIC HTTP/2 · TLS 24,708 24,708 Not possible (no H3→H2) Not possible 21,754 21,754 TWP
Reverse HTTP/3 · QUIC HTTP/3 · QUIC 20,587 20,587 Not possible (no H3 origin) Not possible 16,736 16,736 TWP
MITM HTTP/1 · TLS HTTP/1 · TLS 30,703 30,703 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/2 · TLS HTTP/1 · TLS 38,764 38,764 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/2 · TLS HTTP/2 · TLS 52,372 52,372 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/3 · QUIC HTTP/1 · TLS 24,036 24,036 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/3 · QUIC HTTP/2 · TLS 26,746 26,746 Not possible (no MITM) Not possible Not possible (no MITM) Not possible
MITM HTTP/3 · QUIC HTTP/3 · QUIC 22,346 22,346 Not possible (no MITM) Not possible Not possible (no MITM) Not possible

On this GHA shape, TWP H1 plain ÷ nginx H1 plain ≈ 0.67 (26,808 / 39,886). H1 TLS terminate ≈ 0.70 (22,592 / 32,234). TWP÷YARP H1 plain ≈ 0.94. Absolute RPS swings by VM; prefer the ratio and median across repeats.

nginx HTTP/3: inbound QUIC terminate → cleartext H1 is now in the harness (nginx-reverse-http3-cleartext, nginx.org 1.31.4). HttpClient/MsQuic negotiates 3.0 and peaks at ~19k RPS, but the error rate stays above the 0.1% SLO (sustain 0). nginx still cannot speak HTTP/3 to an origin (no H3 upstream in this conf), so H3-origin rows stay blank.

YARP HTTP/3: compare-same now includes H3→H3; compare-bridges fills H3→H2. TWP leads both (20,587 / 16,736 and 24,708 / 21,754).

Tiny JSON reverse is nginx’s best case on Linux

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).

Why isn’t HTTP/3 > HTTP/2 > HTTP/1 in raw RPS?

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.

Heavier reverse workloads

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 is not published — rechecked at concurrency 8 after H2/H3 streaming work (rps-ramp-20260817-212421): TWP H3 through the UDP shim stayed at 0 sustain (multi-second p99); YARP H3 via the TCP shim also failed to establish. Treat as a measurement limitation (MsQuic + lossy shim), not a capability claim.

Windows — heavier reverse GET (64 KiB / 256 KiB)

1-repeat; warmup 2s / measure 8s; concurrency 8–64. Source: windows-20260820-quick/compare-bodies.

Body Client Origin TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
64 KiB HTTP/1 · TLS HTTP/1 · plain 11,414 12,184 1,119 1,184 12,664 13,403 YARP
64 KiB HTTP/2 · TLS HTTP/1 · plain 5,778 5,792 1,030 1,063 8,081 8,323 YARP
64 KiB HTTP/3 · QUIC HTTP/1 · plain 1,109 1,109 Not possible (no QUIC) Not possible 3,108 3,108 YARP
256 KiB HTTP/1 · TLS HTTP/1 · plain 3,260 3,260 292 301 3,882 3,934 YARP
256 KiB HTTP/2 · TLS HTTP/1 · plain 1,294 1,353 236 239 2,253 2,347 YARP
256 KiB HTTP/3 · QUIC HTTP/1 · plain 771 771 Not possible (no QUIC) Not possible 939 939 YARP

nginx/Windows collapses on large reverse bodies in this harness; treat as same-OS only.

Linux — heavier reverse GET (64 KiB / 256 KiB)

Median of 3 repeats. Source: Actions 32335540288 (compare-bodies). Warmup 2s / measure 8s.

Body Client Origin TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
64 KiB HTTP/1 · TLS HTTP/1 · plain 6,213 6,241 7,874 7,904 6,339 6,383 nginx
64 KiB HTTP/2 · TLS HTTP/1 · plain 4,612 4,612 3,417 3,433 4,735 4,735 YARP
64 KiB HTTP/3 · QUIC HTTP/1 · plain 3,181 3,181 Not measured Not measured 4,201 4,201 YARP
256 KiB HTTP/1 · TLS HTTP/1 · plain 2,068 2,068 2,607 2,607 2,114 2,114 nginx
256 KiB HTTP/2 · TLS HTTP/1 · plain 1,354 1,354 910 935 1,338 1,338 TWP
256 KiB HTTP/3 · QUIC HTTP/1 · plain 1,184 1,184 Not measured Not measured 1,238 1,238 YARP

On Linux H1 TLS, TWP÷nginx ≈ 0.79 at 64 KiB and ≈ 0.79 at 256 KiB — better than the tiny-GET ≈0.67 ratio, but nginx still leads sustain when both stay healthy. nginx H2 at 256 KiB stayed healthy this pass (~910 sustain). nginx HTTP/3 body arms ran before the dual-stack QUIC listen fix and never negotiated h3 — left unmeasured rather than published as a fair zero.

Windows — POST 64 KiB request + 64 KiB response

1-repeat; warmup 2s / measure 8s. Source: windows-20260820-quick/compare-post.

Client Origin TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
HTTP/1 · TLS HTTP/1 · plain 6,003 6,160 383 413 5,264 5,420 TWP
HTTP/2 · TLS HTTP/1 · plain 3,519 3,519 357 389 4,741 4,871 YARP
HTTP/3 · QUIC HTTP/1 · plain 0 185 Not possible Not possible 1,890 1,890 YARP

TWP wins H1 POST (~1.14× YARP) and sits at ~0.74 of YARP on H2 POST. H3 POST did not hold the SLO (sustain 0).

Linux — POST 64 KiB request + 64 KiB response

Median of 3 repeats. Source: Actions 32335541836 (compare-post).

Client Origin TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
HTTP/1 · TLS HTTP/1 · plain 3,885 3,885 4,041 4,041 3,112 3,112 nginx
HTTP/2 · TLS HTTP/1 · plain 2,763 2,763 1,929 1,990 2,468 2,525 TWP
HTTP/3 · QUIC HTTP/1 · plain 0 2,033 Not measured Not measured 2,592 2,592 YARP

Linux nginx H1/H2 POST completed this pass (nginx.org 1.31.4; the previous Ubuntu 1.24 arm returned 100% errors). TWP H3 POST peaked at 2,033 but did not hold the error/latency SLO (sustain 0). nginx HTTP/3 POST used the pre-fix IPv4-only QUIC listen and is left unmeasured.

Windows — lossy / high-RTT (H2 HOL)

Userspace 5 ms one-way delay + 1% connection stall; 64 KiB GET. 1-repeat. Source: windows-20260820-quick/compare-lossy.

Client Origin TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
HTTP/1 · TLS HTTP/1 · plain 287 536 674 674 678 678 YARP
HTTP/2 · TLS HTTP/1 · plain 14 14 14 15 15 15 YARP

H1 stays usable; 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.

Linux — lossy / high-RTT (H2 HOL)

Median of 3 repeats. Source: Actions 32335543372 (compare-lossy).

Client Origin TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
HTTP/1 · TLS HTTP/1 · plain 1,168 1,168 1,220 1,220 1,217 1,217 nginx
HTTP/2 · TLS HTTP/1 · plain 40 44 40 44 40 40 nginx

Same story as Windows: H1 stays usable; H2 falls to tens of RPS for all three products. Tiny-GET H1 leadership does not carry over.

TLS termination cost (H1 TLS → cleartext origin)

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.

Windows

1-repeat. Source: windows-20260820-quick/compare-tls-cost.

Workload TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
Keep-alive · tiny GET 35,724 39,386 17,148 18,318 39,905 39,905 YARP
New-connection · tiny GET 926 926 789 800 1,088 1,088 YARP
Keep-alive · 256 KiB GET 3,349 3,349 291 299 3,916 3,916 YARP

Linux

Median of 3 repeats. Source: Actions 32335545066 (compare-tls-cost).

Workload TWP sustain TWP peak nginx sustain nginx peak YARP sustain YARP peak Winner
Keep-alive · tiny GET 18,880 18,880 28,245 28,245 20,746 20,746 nginx
New-connection · tiny GET 1,106 1,113 1,005 1,009 957 957 TWP
Keep-alive · 256 KiB GET 2,126 2,126 2,627 2,627 2,134 2,134 nginx

Verdict (Linux, authoritative nginx): On keep-alive tiny terminate, TWP is ~0.67× nginx and ~0.91× YARP. On new-connection terminate, TWP is ahead of both (~1.10× nginx, ~1.16× YARP). With 256 KiB bodies, nginx still leads sustain when all three are healthy.

Other measurements

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

Raising limits on large hosts

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;
};

Clone this wiki locally