Skip to content
github-actions[bot] edited this page Aug 16, 2026 · 29 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.

For pooling knobs and certificate first-visit tuning, see Performance and pooling.

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
Harness RpsLoadProbe Release; arms run sequentially

Linux (GitHub-hosted ubuntu-latest)

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-cost

Windows — Titanium vs nginx

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

Linux — Titanium vs nginx

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.

Tiny JSON is nginx’s best case (and TWP’s worst)

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.

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 arms are not published yet (MsQuic + multi-connection load hangs through the shim).

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

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.

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

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.

Windows — POST 64 KiB request + 64 KiB response

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

Linux — POST 64 KiB request + 64 KiB response

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.

Windows — lossy / high-RTT (H2 HOL)

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.

Linux — lossy / high-RTT (H2 HOL)

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.

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 has no equivalent hook.

Windows

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

Linux

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.

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