Feature charts are usually written by the vendor who wins them. This one is too — so every
value in the PQ Crypta column below is established from the pqcrypta-proxy
source tree at v0.2.2 (79,912 lines of Rust across 70 files), from the running configuration on
the live edge, or from a probe against the production endpoint, rather than from a description of
the software. The evidence for each is listed on the Method tab, so the claims can be checked.
Competitor columns come from each project's own documentation, release notes and issue tracker.
228 features across 13 categories.
55 of them have no equivalent anywhere else in the field;
6 are ones where somebody beats us, and those are named
rather than omitted.
What This Compares
Nine proxies, chosen because they are what an operator actually evaluates: the two incumbents in both their free and paid forms, the service-mesh data plane, the two config-simplicity challengers, and the web server that shipped HTTP/3 before anyone else.
| Proxy | Edition compared | Why it is here |
|---|---|---|
| PQ Crypta Proxy | 0.2.2, MIT/Apache-2.0 | The subject. Rust, single binary. |
| NGINX | Open Source, mainline | The default answer for two decades. |
| NGINX Plus | F5, licensed, with App Protect | Where NGINX's WAF, active health checks and cache purge actually live. |
| HAProxy | Community | The load-balancing reference implementation. |
| HAProxy Enterprise | HAProxy Technologies | Where its WAF, native JA3/JA4 and PQC builds live. |
| Envoy | CNCF, current | The service-mesh data plane; the only other proxy here with CONNECT-UDP. |
| Caddy | 2.10+ | The only other proxy in this set with automated server-side ECH. |
| Traefik | 3.5+ | Container-native ingress; added X25519MLKEM768 in 3.5.0. |
| LiteSpeed | LSWS / OpenLiteSpeed | Shipped HTTP/3 in 2019, before anyone else on this list. |
The Scorecard
Each graded row scores 2 for a native implementation, 1 for a partial one — an add-on module, a third-party plugin, or a capability locked behind a paid tier — and 0 for absent. 226 of the 228 rows are graded this way; the rest carry a literal value such as a rule count. This is a breadth measure and nothing more: it counts whether a capability exists, never how fast, how mature, or how well audited it is. A proxy that does eight things superbly can score below one that does forty adequately, and for many deployments the first is the right choice.
- PQ Crypta Proxy 0.2.2 98% 220 native · 2 partial
- HAProxy Enterprise 56% 107 native · 39 partial
- NGINX Plus F5, licensed 53% 101 native · 39 partial
- Envoy CNCF 48% 91 native · 36 partial
- HAProxy Community 44% 75 native · 51 partial
- Caddy v2.10+ 44% 70 native · 60 partial
- NGINX Open Source 39% 70 native · 36 partial
- Traefik v3.5+ 37% 63 native · 40 partial
- LiteSpeed LSWS / OLS 33% 50 native · 50 partial
Each bar is one proxy's 226 graded rows: the solid segment is what it implements natively, the second segment what it reaches only through a module, a third-party plugin or a paid tier. The full row-by-row data is the table on the Matrix tab.
How to Read a Cell
The distinction between the first two symbols is where most of the argument lives, so it is drawn strictly. NGINX can serve X25519MLKEM768 — if you rebuild it against OpenSSL 3.5, which the distribution packages do not do, so that is a full mark for the software and a caveat in the note. HAProxy Community's post-quantum curves are only present in builds shipped by HAProxy Technologies, so that is partial. NGINX's ModSecurity WAF reached end of life in March 2024 and the replacement is the licensed App Protect, so NGINX Open Source scores zero for a WAF and NGINX Plus scores full. A capability that exists only as a community plugin is partial no matter how good the plugin is, because it is not what you get when you install the thing.
A note on our own column. Validation establishes what the code does rather than checking the documentation — but it doubles as a check, and every claim tested traces back to an implementation. The evidence for each is on the Method tab, so the working is visible rather than asserted.
PQ Crypta Proxy 0.2.2 against the field — 228 features
| Feature | PQ Crypta Proxy 0.2.2 | NGINX Open Source | NGINX Plus F5, licensed | HAProxy Community | HAProxy Enterprise | Envoy CNCF | Caddy v2.10+ | Traefik v3.5+ | LiteSpeed LSWS / OLS |
|---|---|---|---|---|---|---|---|---|---|
| Protocols & Transport | |||||||||
| HTTP/1.1 reverse proxying | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| HTTP/2 (RFC 9113) | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| HTTP/3 (RFC 9114) | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| QUIC v1 (RFC 9000) | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Advertise QUIC v1 only in Version NegotiationThe QUIC stack default also lists draft-29..34, which it cannot actually handshake; the listener overrides supported_versions so VN advertises only what the endpoint really speaks, plus the RFC 9287 GREASE version. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Multipath QUIC (draft-ietf-quic-multipath)Concurrent data-carrying paths, per-path packet-number spaces, per-path loss recovery and congestion control, ACK_MP / PATH_ABANDON / PATH_STATUS / MAX_PATH_ID / PATHS_BLOCKED, per-path CID spaces and a scheduler. Budget set by server.max_concurrent_multipath_paths. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| QUIC connection migration — operator controlEvery QUIC stack implements migration; only here is it a config switch (enable_quic_migration). | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| QUIC Retry / source-address validation | ● | ● | ● | ● | ● | ◑ | ◑ | ◑ | ◑ |
| QUIC ACK Frequency extension (draft-ietf-quic-ack-frequency)Negotiated, so inert against clients that lack it; cuts ACK traffic and CPU on high-throughput connections. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| WebTransport — native server sessionsBidirectional streams, unidirectional streams and datagrams, handled in-process rather than passed through. | ● | ○ | ○ | ○ | ○ | ◑ | ○ | ○ | ○ |
| WebTransport origin validation (allowlist) | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| WebTransport per-origin rate limitingMax sessions per origin, streams per session, datagrams per second. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| WebTransport per-domain SNI certificate selection | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| MASQUE / CONNECT-UDP (RFC 9298)UDP relayed over HTTP/3 Extended CONNECT, with host:port allowlist, per-session idle timeout and per-connection session cap. | ● | ○ | ○ | ○ | ○ | ● | ○ | ○ | ○ |
| HTTP Datagrams (RFC 9297) | ● | ○ | ○ | ○ | ○ | ● | ○ | ○ | ○ |
| Extended CONNECT (RFC 8441 / 9220) | ● | ○ | ○ | ○ | ○ | ● | ○ | ○ | ○ |
| WebSocket proxying | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| gRPC-aware proxyingPQ Crypta carries gRPC over its HTTP/2 path but has no gRPC-specific features (no transcoding, no gRPC health protocol). Envoy is the reference implementation here. | ◑ | ● | ● | ● | ● | ● | ● | ● | ◑ |
| 103 Early Hintspreload, dns-prefetch, preconnect, modulepreload and speculative prerender, scoped per host/path and hot-reloadable. | ● | ○ | ○ | ● | ● | ◑ | ○ | ○ | ○ |
| RFC 9218 Extensible Priorities | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Alt-Svc built from the actual listener portsRather than a hardcoded constant, the value is assembled from udp_port plus additional_ports. The TCP value is built once at startup; the QUIC value is built per connection, because it depends on which port that connection arrived on. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| Alt-Svc varies by transport (RFC 7838)RFC 7838 makes Alt-Svc relative to the connection it arrives on, so advertising h3 to a client already on h3 tells it nothing. Measured on each vendor's own deployment 2026-09-10, since this is behaviour you can only observe on a live endpoint. PQ Crypta: h3 on every UDP port over TCP, h2 on the same port plus any other h3 port over QUIC. HAProxy (haproxy.org): h3=":443" over TCP, h2=":443" over QUIC. Caddy (caddyserver.com) scores partial — it does not repeat h3 over h3, but sends no Alt-Svc there at all, so it names no alternative either. NGINX (quic.nginx.org) and LiteSpeed (litespeedtech.com) send the identical h3 advertisement on both transports. NGINX Plus, Envoy and Traefik are left unscored: their own sites serve no HTTP/3, so there was nothing to measure. | ● | ○ | — | ● | ● | — | ◑ | — | ○ |
| Per-host Alt-Svc: clear (force TCP for named hosts)tcp_only_hosts evicts cached QUIC upgrades so a browser returns to TCP/TLS. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Per-host HTTP/1.1-only ALPN suppressionhttp11_only_hosts drops the h2 ALPN entry so a browser opens one TCP connection per fetch instead of coalescing — required for a truthful parallel-stream test. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| PROXY protocol v2Accepted from trusted load balancers (v1 and v2, TLVs validated, CRC32C verified) on every TCP listener, and sent to backends on TLS passthrough routes. | ● | ● | ● | ● | ● | ● | ◑ | ● | ● |
| Unix-socket backends | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| FastCGI / uwsgi / SCGI backend protocolsPQ Crypta speaks HTTP over a Unix socket to PHP-FPM, not the FastCGI wire protocol. | ○ | ● | ● | ○ | ○ | ○ | ● | ○ | ● |
| Generic L4 stream proxying (arbitrary TCP/UDP)PQ Crypta does SNI-based TLS passthrough but is not a general stream load balancer. | ◑ | ● | ● | ● | ● | ● | ◑ | ● | ○ |
| Any number of additional listener ports, QUIC on each | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Request coalescing (dedupe identical in-flight requests) | ● | ● | ● | ○ | ○ | ◑ | ○ | ○ | ◑ |
| Post-Quantum Cryptography | |||||||||
| Hybrid PQC key exchange — X25519MLKEM768Verified on the live edge: OpenSSL s_client reports "Negotiated TLS1.3 group: X25519MLKEM768". | ● | ● | ● | ◑ | ● | ○ | ● | ● | ○ |
| SecP256r1MLKEM768 | ● | ● | ● | ◑ | ● | ○ | ○ | ○ | ○ |
| SecP384r1MLKEM1024Offered on every listener, HTTP/3 included: added to the vendored rustls for QUIC, native in OpenSSL 3.5 for TCP. X448MLKEM1024 is not listed because no TLS codepoint is assigned for it, so no client can offer it. | ● | ◑ | ◑ | ○ | ○ | ○ | ○ | ○ | ○ |
| Pure ML-KEM-1024 groupOffered when preferred_kem or additional_kems names it, on every listener; never under require_hybrid. | ● | ◑ | ◑ | ○ | ○ | ○ | ○ | ○ | ○ |
| Configurable KEM preference order | ● | ● | ● | ● | ● | ● | ● | ○ | ○ |
| ML-DSA-87 (FIPS 204) server certificatesHAProxy states plainly that PQC signatures are not supported yet. Envoy's PQC issue is closed as not planned, blocked on a BoringSSL upgrade. Go's crypto/tls has no ML-DSA, which rules out Caddy and Traefik. | ● | ◑ | ◑ | ○ | ○ | ○ | ○ | ○ | ○ |
| ML-DSA certificate selection per SNI | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| PQC downgrade detectionThe negotiated group is read off the live connection via SSL_get0_group_name rather than assumed. That distinction is the whole feature: a detector that assumes the group it is looking for can never fire, and would report a clean result on a connection that had silently fallen back to classical. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Downgrade action: block (421) / log / allow | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| require_hybrid and minimum PQC security levelBoth shape the groups on every listener, TCP and QUIC alike. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| PQC-sealed TLS 1.3 session ticketsEach ticket carries its own ML-KEM-1024 encapsulation; resumption state is sealed with an AES-256-GCM key derived by HKDF-SHA384, KEM ciphertext as associated data. Keypair rolls on the ticket lifetime, one previous generation retained. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Legacy pre-NIST Kyber768, feature-gated off by defaultCompile-time opt-in with a deprecation warning, for peers that have not migrated to FIPS 203. | ● | ○ | ○ | ○ | ○ | ◑ | ○ | ○ | ○ |
| FIPS-validated crypto build | ● | ◑ | ● | ◑ | ● | ◑ | ◑ | ◑ | ○ |
| TLS, Certificates & ECH | |||||||||
| TLS 1.3 as the default minimum on every listenerOthers permit TLS 1.2 by default and require configuration to raise the floor. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| TLS termination | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| TLS re-encryption to backend, with mTLS | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| TLS passthrough (SNI-routed, no decryption) | ● | ● | ● | ● | ● | ● | ◑ | ● | ○ |
| Client certificate authentication (mTLS)Required of every client, or requested only when a route needs one so the route can refuse; enforced the same way on HTTP/1.1, HTTP/2 and HTTP/3. | ● | ● | ● | ● | ● | ● | ● | ● | ◑ |
| Server-side Encrypted Client Hello (draft-ietf-tls-esni-25)Written into a rustls fork because upstream rustls is client-side only in every version to 0.23.42. HPKE decryption is delegated to rustls's own Hpke::open — no new cryptography, only ClientHello reconstruction ahead of the unmodified handshake. NGINX 1.29.4 has ECH but needs an OpenSSL branch carrying it; HAProxy has a feature request and no implementation; Envoy has an open issue. | ● | ◑ | ◑ | ○ | ○ | ○ | ● | ○ | ○ |
| ECH on QUIC as well as TCPECH is a TLS 1.3 extension and QUIC is TLS 1.3, so the same handshake resolves it — provided the QUIC listener is wired to it. Caddy is marked full here because it documents ECH and HTTP/3 without qualifying either; no limitation is published, and a gap will not be asserted from inference. | ● | ○ | ○ | ○ | ○ | ○ | ● | ○ | ○ |
| ECH HPKE key rotation with previous configs retainedRotated by ech-keygen / ech-rotate.timer; old configs kept so clients holding a cached DNS record still complete. Live: the pqcrypta.com HTTPS record publishes ech=AD/+DQA7ZA... | ● | ○ | ○ | ○ | ○ | ○ | ● | ○ | ○ |
| ECH outcome exposed to the backendx-tls-ech carries accepted / rejected / not-offered. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| 0-RTT early data | ● | ● | ● | ● | ● | ● | ◑ | ○ | ● |
| 0-RTT replay protection with a nonce storestrict / session / none. Everyone else forwards an Early-Data header and makes replay the application's problem. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ○ | ○ | ◑ |
| Per-route 0-RTT control with RFC 8470 425 Too Early | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| OCSP stapling | ● | ● | ● | ● | ● | ◑ | ● | ● | ● |
| TLS certificate compression (RFC 8879)Ours compresses on TCP and on QUIC: zlib on TCP, because these OpenSSL builds carry -DZLIB and nothing else, and brotli or zlib on QUIC, where rustls builds its compressor list from the compiled features. This is the row where the field has caught up. NGINX added ssl_certificate_compression in 1.29.1, off by default, and NGINX Plus R36 is built on 1.29.3. HAProxy 3.4 added tune.ssl.certificate-compression, defaulting to auto, which follows the TLS library. Envoy 1.38.0 covers QUIC and TCP TLS with brotli and zlib. Caddy and Traefik cannot have it at the language level: Go's crypto/tls has no implementation and the proposal, golang/go#42967, is open and unplanned. LiteSpeed lists certificate compression on its SSL performance page, in copy written before the RFC was published, naming neither the algorithms nor which of LSWS and OpenLiteSpeed has it. | ● | ● | ● | ● | ● | ● | ○ | ○ | ● |
| ACME certificate automationNGINX's native ACME module is a preview supporting HTTP-01 only. HAProxy Community has none. | ● | ◑ | ◑ | ○ | ● | ○ | ● | ● | ● |
| ACME DNS-01 challenge | ● | ○ | ○ | ○ | ◑ | ○ | ● | ● | ◑ |
| ACME External Account Binding / arbitrary CA | ● | ◑ | ◑ | ○ | ● | ○ | ● | ● | ◑ |
| On-demand TLS — issue a certificate mid-handshake for an unseen hostnameCaddy answers a ClientHello for a hostname it has never been configured with by obtaining a certificate during the handshake. Every other proxy here, PQ Crypta included, must know the name in advance. | ○ | ○ | ○ | ○ | ○ | ○ | ● | ○ | ○ |
| Certificate Transparency submission after issuanceNew certs POSTed to configured CT logs at /ct/v1/add-chain. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Certificate hot reload without restart or dropped connections | ● | ◑ | ● | ● | ● | ● | ● | ● | ◑ |
| Private key file permission validation at startupstrict_key_permissions aborts boot on insecure permissions. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Multi-certificate SNI resolver | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Handshake facts injected to the backendx-tls-version, x-tls-cipher, x-tls-group, x-tls-alpn, x-tls-ech — each stripped from the inbound request before being set, so a caller cannot assert a handshake it did not have. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ○ | ◑ |
| TLS Fingerprinting & Bot Management | |||||||||
| JA3 fingerprinting on TCPHAProxy Community needs a Lua plugin; Enterprise has it natively. NGINX needs a third-party source patch. | ● | ◑ | ○ | ◑ | ● | ○ | ◑ | ○ | ○ |
| JA4 fingerprinting on TCP | ● | ◑ | ○ | ◑ | ● | ○ | ◑ | ○ | ○ |
| Fingerprint policy identical on every transportNot just present on both, but governed by the same switch: `fingerprint.enabled = false` has to mean the same thing over HTTP/3 as it does over HTTP/1.1. Both the TCP listeners and the QUIC path gate their whole fingerprint stage on that flag, so turning fingerprinting off stops QUIC connections being classified and rate-banned too. Gating one transport and not the other is an enforcement asymmetry with nothing in the configuration to reveal it, and only load shows it up. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| JA3 / JA4 fingerprinting over QUIC and HTTP/3Over QUIC the ClientHello arrives inside encrypted Initial CRYPTO frames, so the vendored rustls and noq forks surface it through HandshakeData for the same extractor the TCP paths use. The JA4 transport marker is q over QUIC, t over TCP. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Operator-supplied fingerprint classification databaseConsulted before the built-in table, matching JA3 then JA4. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Automatic blocking on known-malicious fingerprints | ● | ○ | ○ | ○ | ◑ | ○ | ○ | ○ | ○ |
| Automatic blocking on scanner fingerprints | ● | ○ | ○ | ○ | ◑ | ○ | ○ | ○ | ○ |
| JA3 / JA4 replay detectionFlags one fingerprint arriving from many IPs inside a window — credential stuffing and fingerprint spoofing. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| JA3 / JA4 drift detectionFlags cipher or extension composition changing under a stable hash — a library upgrade, or evasion. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Fingerprint-keyed rate limiting (NAT-friendly)Distinguishes clients sharing one corporate IP. | ● | ○ | ○ | ○ | ◑ | ○ | ○ | ○ | ○ |
| Observed fingerprint corpus persisted and reloadedPre-hash JA3 string, per-fingerprint connection counts and the User-Agents seen carrying them, bounded and evicted least-recently-seen. Evidence only — it never feeds classification or blocking. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Verified crawler check (operators' published IP ranges, forward-confirmed DNS fallback)A declared crawler is only treated as one when its address is inside the ranges its operator publishes (Google, Bing, DuckDuckGo, Apple, OpenAI, Perplexity, Common Crawl, Ahrefs), refreshed every six hours, or, where the operator has a DNS contract, when rDNS and the forward lookup agree. | ● | ○ | ○ | ○ | ◑ | ○ | ○ | ○ | ○ |
| Fingerprint classification cache with TTL and background cleanup | ● | ○ | ○ | ○ | ◑ | ○ | ○ | ○ | ○ |
| Web Application Firewall | |||||||||
| WAF inside the core binary — no module, no sidecar, no licenceNGINX ModSecurity reached end of life in March 2024 and the replacement is the licensed App Protect. HAProxy Community routes to an external ModSecurity daemon over SPOE. | ● | ○ | ● | ◑ | ● | ◑ | ◑ | ◑ | ● |
| Rules shipped in-treeEvery rule carries a stable PQW-* identifier. | 256 | 0 | CRS | CRS | vendor | 0 | CRS | CRS | CRS |
| Severity-weighted anomaly scoring rather than first-match blockingInfo 1, Low 3, Medium 5, High 8, Critical 10. Medium equals the default threshold so one Medium rule blocks alone; Low is over half, so one weak signal is never enough and two corroborating ones are. | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| SQLi, XSS and path traversal detection | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| NoSQLi, SSTI, JNDI, prototype pollution, GraphQL detection | ● | ○ | ◑ | ◑ | ◑ | ○ | ◑ | ◑ | ◑ |
| Command injection, XXE, insecure deserialization, LFI, CRLF | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| Structural request-smuggling detectionA second Content-Length, or Content-Length beside Transfer-Encoding — scored as a rule, not merely rejected by the parser. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| Scanner probe paths blocked before pattern scanning54 rules covering .git, .env, .aws, wp-login.php, terraform state, SSH keys and CI/CD files — automated scanners never reach the backend or pollute the error-rate metrics. | ● | ○ | ◑ | ◑ | ◑ | ○ | ◑ | ◑ | ◑ |
| Bad-bot User-Agent rule set58 rules. | ● | ○ | ◑ | ◑ | ◑ | ○ | ◑ | ◑ | ◑ |
| Request body scanning, JSON aware, size capped | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| Compressed request bodies decoded before scanningMeasured on five stacks, not read off documentation, because the documentation does not say. A compressed request body is entropy to a pattern engine, so a WAF that scans it raw is scanning noise. Method: a payload the WAF blocks in the clear (positive control), then the same payload across 12 different compressed streams of it \u2014 four zlib levels by three paddings. Decoding blocks all 12, since the decoded content is identical; incidental matching gives a mixed result. A 403 alone proves nothing: CRS blocked a gzipped SQLi here on rule 921150 "CR/LF detected", matched against ARGS_NAMES holding the raw deflate stream, having never seen the payload. Results, all with the positive control passing and 12/12 benign compressed bodies accepted: nginx 1.29 + ModSecurity 3.0.16 + CRS 3.3.10, HAProxy 3.0.27 + coraza-spoa, Caddy 2 + coraza-caddy v2, Envoy + coraza-proxy-wasm, and OpenLiteSpeed 1.9.2 + mod_security 1.4 (libmodsecurity 3.0.15) \u2014 0 of 12 blocked on every one; the payload reached the origin still compressed. Two engines, five integrations, same answer. ModSecurity cannot be configured out of it: the directive does not exist (SecRequestBodyAccess, -Limit, -LimitAction, -NoFilesLimit, -JsonDepthLimit, and nothing for Content-Encoding). CRS masks it by default via 920450 (content-encoding is a restricted header) and 920270 (a null byte in the compressed stream) \u2014 fail-closed by accident, which also 403s every legitimate gzipped API call; remove those two, as anyone accepting gzip must, and blocking is a coin flip. Traefik is graded with Coraza, whose library was measured in three separate integrations. Ours decodes gzip, deflate, brotli and zstd (decode_compressed_body, on by default) capped at 1 MiB so a compression bomb cannot turn inspection into memory exhaustion, keeps the decoded prefix when a stream is truncated so a broken tail cannot hide a payload, and blocked 12/12 on both encodings via PQW-SQLI-002 \u2014 a content rule, on the decoded body. NGINX Plus (App Protect, a different F5 engine) is the one cell NOT measured, for want of a subscription, and keeps its documented grade. Reproduce with pentests/scripts/compressed-body-decode.py. | ● | ○ | ● | ○ | ○ | ○ | ○ | ○ | ○ |
| All headers scanned, with header count and size caps | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| Multi-pass decoding of nested encodings | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| Per-path rule and category exclusions | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| Detect-only mode alongside block mode | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| Per-route WAF enable and mode override | ● | ○ | ● | ◑ | ● | ○ | ◑ | ◑ | ◑ |
| X-Forwarded-For exempted from SSRF patternsLoopback and RFC1918 addresses in a forwarded chain are legitimate proxy hops, not SSRF — this is a false-positive class that bites everyone running behind another proxy. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Regression corpus, baseline and history gating CIpentests/regression: a labelled corpus, a committed baseline and a run history; the gate fails the build on exit code. It has already caught a loopback false positive. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Rate Limiting & DoS Protection | |||||||||
| Per-IP request rate limiting | ● | ● | ● | ● | ● | ● | ◑ | ● | ● |
| Connection rate limiting — new connections per secondDistinct from a concurrency cap: a client that opens and closes as fast as it can never trips max_connections_per_ip. | ● | ○ | ○ | ● | ● | ◑ | ○ | ○ | ◑ |
| Concurrent connections per IP | ● | ● | ● | ● | ● | ● | ◑ | ○ | ● |
| Multi-dimensional keys: IP, JA3, JA4, JWT subject, API key, headerA resolution table, not a single string; the first key derivable from the request wins, with a configured fallback. | ● | ◑ | ◑ | ◑ | ● | ● | ◑ | ◑ | ◑ |
| Composite keys (several dimensions combined) | ● | ○ | ○ | ◑ | ◑ | ● | ○ | ○ | ○ |
| JWT subject limiting with signature verification firstHMAC-SHA256 verified before the sub claim is trusted; unsigned tokens and non-HMAC algorithms rejected. Limiting on an unverified claim is limiting on attacker-chosen input. | ● | ○ | ● | ○ | ● | ● | ○ | ○ | ○ |
| Per-route limit overrides | ● | ● | ● | ● | ● | ● | ◑ | ● | ◑ |
| Adaptive baseline / statistical anomaly detectionLearns normal traffic and flags deviation by standard-deviation multiplier. | ● | ○ | ◑ | ○ | ● | ○ | ○ | ○ | ○ |
| Distributed limiting across instancesPQ Crypta uses Redis with atomic Lua token-bucket and fixed-window scripts. HAProxy uses stick-table peers; Envoy needs an external RLS; NGINX Plus uses zone sync. | ● | ○ | ● | ◑ | ● | ● | ◑ | ◑ | ○ |
| IPv6 subnet aggregationA /64 is one client, not 2^64 of them. | ● | ● | ● | ● | ● | ◑ | ◑ | ○ | ○ |
| Automatic IP blocking on threshold, with expiry | ● | ○ | ○ | ◑ | ● | ○ | ○ | ○ | ○ |
| Error-rate driven automatic banningWindowed 4xx/5xx rate with a minimum-request floor, so one early error cannot ban a client. | ● | ○ | ○ | ○ | ◑ | ○ | ○ | ○ | ○ |
| GeoIP country blocking (MaxMind) | ● | ◑ | ● | ◑ | ● | ◑ | ◑ | ◑ | ◑ |
| GeoIP block expirygeoip_block_duration_secs sets how long a country block holds, default 24 hours, so a block ages out on its own rather than accumulating indefinitely. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Database-synced IP and CIDR blocklistsCIDR ranges match whole subnets. The reload reconciles in both directions — entries that disappear are released, so unblocking in the dashboard actually lifts a live block. Only DatabaseSync entries reconcile; blocks the proxy raised itself are untouched. | ● | ○ | ◑ | ○ | ● | ○ | ○ | ○ | ○ |
| Immediate unblock endpoint that reaches every listenerPOST /blocklist/unblock/:ip drops the IP and any CIDR entry covering it without waiting for the next sync. | ● | ○ | ● | ○ | ● | ○ | ○ | ○ | ○ |
| One shared security state across every listener and transportBlocked IPs, rate-limiter counters, the fingerprint corpus and synced blocklists live in one SecurityState that QUIC and all three TCP paths share, so a block raised on any transport is enforced on every one of them and the admin API reaches them all. A per-listener copy would let a client refused on one port simply continue on another. | ● | ◑ | ◑ | ● | ● | ◑ | ◑ | ◑ | ◑ |
| Request body size limit with 413 | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Header count and size limits | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Zero-Trust & Hardening | |||||||||
| zero_trust_mode startup validationRefuses to boot unless mTLS is required, no backend is plaintext, no CIDR is trusted and the admin API requires proof of possession — an HMAC-signed request or a client certificate. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Per-route HMAC proof-of-possessionHMAC-SHA256 over the full path and query string. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Nonce-bound replay preventionX-Request-Nonce folded into the signature, 300-second window. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Per-route client certificate requirementmtls_required on any route; internal = true routes default to it. A client-supplied x-client-cert header is stripped on every listener. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Admin API HMAC proof-of-possession alongside bearer token | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Admin API loopback enforcement, or mTLSrequire_loopback defaults on and aborts startup when the plain-HTTP admin API is bound to a non-loopback address — a cleartext token on a public interface is a credential leak, not a warning. With require_mtls the admin API serves TLS 1.3 and requires a client certificate instead, and may then listen off loopback. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Admin brute-force lockout with exponential back-offPer-IP and global, 5 minutes base up to 30. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| SSRF protection on backend addresses at config loadLink-local rejected outright; RFC1918 warns and needs explicit acknowledgement. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Insecure backend TLS blocked unless explicitly flaggedtls_skip_verify = true is refused at config load without --allow-insecure-backends. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Log injection preventionNewlines and control characters stripped from every user-controlled field before it reaches an access or audit log. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| Subprocess environment sanitisationEvery openssl subprocess runs with env_clear() against PATH and LD_PRELOAD injection. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Memory-safe core with unsafe code denied at the lint levelunsafe_code = "deny" crate-wide; one targeted allow in pqc_tls.rs for the OpenSSL FFI binding. NGINX, HAProxy and LiteSpeed are C or C++. | ● | ○ | ○ | ○ | ○ | ○ | ◑ | ◑ | ○ |
| Runtime self-verification at startupA real in-memory TLS 1.3 handshake against the proxy's own ServerConfig, plus live probes of the WAF, rate limiter, mTLS and every bound listener. Anything not probed is labelled "configured" and is never allowed to masquerade as a measurement. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Load Balancing & Resilience | |||||||||
| Load balancing algorithms shippedHAProxy remains the reference for breadth. NGINX looks smaller than it is: its generic `hash <key> [consistent]` covers URI, query-parameter and header hashing in one directive, and `random two least_conn` is power-of-two-choices. | 11 | 6 | 7 | 10+ | 10+ | 6 | 8 | 2 | 3 |
| least_connections | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| round_robin | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Smooth weighted round-robin | ● | ● | ● | ● | ● | ● | ● | ◑ | ● |
| Random selection | ● | ● | ● | ● | ● | ● | ● | ○ | ○ |
| IP hash / consistent hashing | ● | ● | ● | ● | ● | ● | ● | ○ | ◑ |
| Least response time (moving average) | ● | ○ | ● | ● | ● | ◑ | ● | ○ | ○ |
| Power of two choicesSample two backends at random, take the less loaded. Envoy's default, NGINX's `random two least_conn`, Caddy's `random_choose 2`, Traefik's `p2c` since 3.4. Near least-connections quality without the O(n) scan or the herd effect least-conn shows under burst. HAProxy is the notable absence. | ● | ● | ● | ○ | ○ | ● | ● | ● | ○ |
| URI hash — affinity by request pathHAProxy `uri`, NGINX `hash $request_uri`, Caddy `uri_hash`. Gives a tier of caching backends a disjoint slice of the URL space each. Envoy has no dedicated path policy but reaches the same result by hashing the `:path` pseudo-header. | ● | ● | ● | ● | ● | ● | ● | ○ | ○ |
| Query-parameter hashHAProxy `url_param`, NGINX `hash $arg_name`, Caddy `query`, Envoy's `query_parameter` hash policy. Pins a session when its identifier travels in the URL rather than a cookie. | ● | ● | ● | ● | ● | ● | ● | ○ | ○ |
| Request-header hashHAProxy `hdr()`, NGINX `hash $http_name`, Caddy `header`. Pins by tenant, API key or device without a cookie. | ● | ● | ● | ● | ● | ● | ● | ○ | ○ |
| First available — fill in order, keep spare capacity idleHAProxy `first`, Caddy `first`. The opposite of spreading load, deliberately: it is how you keep headroom genuinely cold so it can be scaled in or powered down. | ● | ○ | ○ | ● | ● | ◑ | ● | ○ | ○ |
| Hashing with bounded disruption when a backend failsThe property that makes a hash algorithm worth using: losing one backend of N must move only its own share of keys, not re-home the pool. Consistent hashing (NGINX ketama, HAProxy `hash-type consistent`, Envoy ring-hash and Maglev) or rendezvous/HRW (Caddy and PQ Crypta). A plain `hash % len` does not qualify: it re-homes almost every key on a single failure. | ● | ● | ● | ● | ● | ● | ● | ○ | ○ |
| Backend pools per route | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Per-server weight | ● | ● | ● | ● | ● | ● | ● | ◑ | ● |
| Per-server priority failover tiers | ● | ● | ● | ● | ● | ● | ◑ | ○ | ● |
| Sticky sessions — cookie | ● | ○ | ● | ● | ● | ● | ● | ● | ● |
| Sticky sessions — custom header | ● | ○ | ● | ● | ● | ● | ● | ○ | ○ |
| Session affinity TTL eviction, per-mode mapsEach affinity mode keeps its own map and evicts stale entries. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| Passive health-aware routing | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Proactive active health checksBackground TCP-connect checks mark a backend unreachable before traffic hits it. NGINX Open Source has passive checks only. | ● | ○ | ● | ● | ● | ● | ● | ● | ◑ |
| Circuit breaker with automatic recovery | ● | ○ | ◑ | ◑ | ● | ● | ○ | ● | ○ |
| Per-backend circuit-breaker overridesFailure threshold, half-open delay and success threshold per backend. | ● | ○ | ○ | ○ | ◑ | ● | ○ | ○ | ○ |
| Per-backend retry with exponential backoffRetry on 5xx, connect failure or timeout. | ● | ◑ | ● | ● | ● | ● | ● | ◑ | ◑ |
| Connection draining on backend removalIn-flight requests complete before the backend leaves rotation. | ● | ○ | ● | ● | ● | ● | ○ | ○ | ◑ |
| Request queuing when every backend is saturated | ● | ○ | ● | ● | ● | ◑ | ○ | ○ | ◑ |
| Slow start for recovering backendsAvoids the thundering herd when a circuit breaker reopens. | ● | ○ | ● | ● | ● | ● | ○ | ○ | ○ |
| Per-backend connection pool tuningIdle timeout, max idle, max total, acquire timeout. | ● | ● | ● | ● | ● | ● | ● | ● | ◑ |
| Backend request timeout bounding the whole requestA hung backend returns 504 instead of holding the client connection open — connection-setup timeouts alone do not do this. | ● | ● | ● | ● | ● | ● | ● | ● | ◑ |
| Canary / percentage traffic splitting | ● | ◑ | ● | ◑ | ● | ● | ◑ | ● | ○ |
| Sticky canary assignmentA PQCPROXY_CANARY cookie keeps a client on the same side for the duration of the experiment. | ● | ○ | ◑ | ○ | ◑ | ● | ○ | ◑ | ○ |
| Canary auto-rollback on sliding-window error rateThe canary suspends itself when its error rate crosses the threshold — no human in the loop. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Live canary control without restartGET /canary, POST /canary/suspend/:id, /resume/:id, /weight/:id. | ● | ○ | ● | ○ | ● | ● | ○ | ○ | ○ |
| Traffic shadowing / mirroringFire-and-forget copy to a secondary backend; the client only ever sees the primary response. Active on HTTP/1.1, HTTP/2 and HTTP/3. | ● | ● | ● | ○ | ◑ | ● | ○ | ○ | ○ |
| Per-server QUIC keep-alive interval | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Caching & Compression | |||||||||
| Response cacheHAProxy's is a deliberately small object cache. Envoy's cache filter is alpha. Traefik has none. | ● | ● | ● | ◑ | ◑ | ◑ | ◑ | ○ | ● |
| RFC 9111 Cache-Control parsingmax-age, s-maxage, no-cache, no-store, private, public. | ● | ● | ● | ◑ | ◑ | ◑ | ◑ | ○ | ● |
| ETag / If-None-Match conditional 304Strong and weak comparison. | ● | ● | ● | ○ | ○ | ◑ | ◑ | ○ | ● |
| Last-Modified / If-Modified-Since conditional 304 | ● | ● | ● | ○ | ○ | ◑ | ◑ | ○ | ● |
| Vary support with a guard on unsafe Vary valuesA stored Vary: Cookie or Vary: Authorization is a cross-user leak waiting to happen; those responses are not stored. | ● | ● | ● | ○ | ○ | ◑ | ◑ | ○ | ● |
| Cache purge API — everything, by host, by prefix, or one exact URLpath= without host= is refused rather than silently widened. Audit-logged with scope and target. In NGINX this is a Plus-only feature. | ● | ○ | ● | ○ | ◑ | ○ | ◑ | ○ | ● |
| One cache instance per process, shared by every listenerThe key is METHOD|host|path, with no port or protocol in it, so one entry serves h2, h3 and every listening port, and a purge clears the resource everywhere at once. Per-listener caches would store the same URL several times over and let a purge miss most of the copies. | ● | ● | ● | ● | ● | ● | ● | ○ | ● |
| Correct HEAD semantics (RFC 9110 §9.3.2)A HEAD response carries the headers a GET would. Synthesizing content-length: 0 from an empty buffered body tells every link checker, uptime monitor and scanner that a dynamic page is empty. Fixed on all three transports. | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Hop-by-hop header stripping before caching and forwardingTransfer-Encoding, Connection, Keep-Alive, Proxy-Connection, Upgrade, TE, Trailer, Proxy-Authenticate, Proxy-Authorization — leaving these on causes ERR_QUIC_PROTOCOL_ERROR on HTTP/3. | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Brotli | ● | ◑ | ◑ | ○ | ◑ | ● | ● | ● | ● |
| Zstandard | ● | ◑ | ◑ | ◑ | ◑ | ● | ◑ | ● | ○ |
| gzip and deflate | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Content-type aware compression, already-compressed skipped | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Routing & Request Handling | |||||||||
| Host-based routing | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Path prefix and exact matching | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Path regex matching with a ReDoS size guardEveryone matches regex; the pattern size limit is what stops a config from becoming a denial of service. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| Per-route timeout override | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Per-route request and response header add / remove | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Per-route CORS with preflight handling | ● | ◑ | ◑ | ◑ | ◑ | ● | ◑ | ● | ◑ |
| CORS headers on 429 rate-limit responsesWithout them a browser reports a rate limit as a CORS failure and the developer chases the wrong bug. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Per-route redirect | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Per-route security policy blockWAF on/off and mode, rate limits, JA3 allowlist, mTLS, HMAC signing, and skip_bot_blocking for routes that serve automated clients — applied before the global checks. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Response JSON field strippingRemoves named fields from a backend JSON response before it reaches the client. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Cookie security attribute enforcementSecure, HttpOnly and SameSite injected onto backend Set-Cookie headers. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ○ | ○ | ○ |
| Path normalization as an explicit switch | ● | ◑ | ◑ | ◑ | ◑ | ● | ◑ | ◑ | ◑ |
| HTTP to HTTPS redirect with Host validationAn unknown Host receives 400 rather than an open redirect. | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ● | ● | ◑ |
| Client identity forwarding to backendX-Real-IP, X-Forwarded-For, X-Forwarded-Proto, plus client certificate details. | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Observability | |||||||||
| Prometheus metrics endpointNGINX Open Source exposes stub_status only. | ● | ○ | ◑ | ● | ● | ● | ● | ● | ○ |
| Latency percentiles p50 / p95 / p99 | ● | ○ | ● | ● | ● | ● | ◑ | ◑ | ○ |
| JSON metrics endpoint | ● | ○ | ● | ● | ● | ● | ○ | ○ | ○ |
| Health-check traffic excluded from metricsOtherwise a 1-second liveness probe dominates every percentile you compute. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| OpenTelemetry OTLP export | ● | ● | ● | ◑ | ● | ● | ◑ | ● | ○ |
| W3C TraceContext propagation | ● | ● | ● | ◑ | ● | ● | ◑ | ● | ○ |
| B3 multi-header and single-header propagation | ● | ◑ | ◑ | ◑ | ◑ | ● | ◑ | ● | ○ |
| Trace propagation on HTTP/3 and WebTransportA composite propagator on every transport, not only the TCP ones. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Trace ID correlated into the access log | ● | ● | ● | ◑ | ● | ● | ◑ | ◑ | ○ |
| Structured access log, JSON or text | ● | ● | ● | ● | ● | ● | ● | ● | ◑ |
| Structured security audit logAdmin actions, auth failures, WAF blocks and detects, IP blocks, rate-limit hits, PQC downgrades, JA3 replay and drift, config and TLS reloads — written asynchronously as JSON. | ● | ○ | ◑ | ○ | ◑ | ○ | ○ | ○ | ○ |
| Server-Timing header with proxy latency breakdownLive: server-timing: proxy;dur=4.45;desc="PQCProxy Processing", quic;desc="QUIC v1". | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| NEL and Report-To headers | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| Health endpoints for QUIC and WebTransport specifically | ● | ○ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ | ◑ |
| Operations | |||||||||
| Raises its own file-descriptor limit at startupA proxy is a descriptor multiplier — one per client connection, one per backend connection — so a stock 1024 soft limit caps a process near 500 concurrent connections whatever max_connections says, and past that it answers 5xx while looking perfectly healthy. HAProxy computes and raises its own from maxconn. PQ Crypta now lifts the soft limit to the hard limit before any socket opens, and warns with the effective ceiling when it cannot. Found by benchmarking, not by theory: at the stock limit, HTTP/2 at 1000 concurrent streams returned 100% 5xx — 100,829 of 100,829 requests. | ● | ○ | ○ | ● | ● | ○ | ○ | ○ | ○ |
| Hot configuration reload without dropping connectionsRoutes, backends, headers and middleware rebuild on reload and apply from the next request, on open keep-alive connections too; bind addresses and ports need a restart. | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Config file watching with automatic reload--watch-config; no signal, no orchestration. | ● | ○ | ○ | ○ | ○ | ● | ◑ | ● | ○ |
| Environment config overlay--env <name> merges config.<name>.toml over the base config. | ● | ○ | ○ | ○ | ○ | ◑ | ○ | ◑ | ○ |
| Config schema versioningA version field; warns when absent, errors when newer than the binary understands. | ● | ○ | ○ | ○ | ○ | ● | ○ | ○ | ○ |
| Startup validation of conflicting settingsNot a syntax check: PQC with passthrough, 0-RTT on non-safe routes without replay protection, mTLS required with no CA configured. | ● | ◑ | ◑ | ◑ | ◑ | ● | ● | ◑ | ◑ |
| Log rotation via SIGHUP, logrotate compatible | ● | ● | ● | ◑ | ◑ | ● | ◑ | ● | ● |
| Graceful shutdown drain polling active connectionsPolls at 100 ms and exits the moment connections reach zero — an idle restart is not penalised. | ● | ● | ● | ● | ● | ● | ● | ● | ● |
| Admin / runtime control API29 routes: health (including QUIC and WebTransport), metrics, reload, TLS reload, configuration, shutdown, blocklist with manual block and unblock, security status, GeoIP lookup, fingerprints, threats, cache, canary, backends, ACME, OCSP, rate limits and per-client reset. | ● | ○ | ● | ● | ● | ● | ● | ● | ◑ |
| Single static binary, no runtime dependencies | ● | ○ | ○ | ○ | ○ | ◑ | ● | ● | ○ |
| Linux, macOS and Windows | ● | ◑ | ◑ | ◑ | ◑ | ◑ | ● | ● | ◑ |
| Windows service integration | ● | ○ | ○ | ○ | ○ | ○ | ● | ◑ | ○ |
| Dynamic service discovery (Docker, Kubernetes, Consul, xDS)This is Traefik's and Envoy's home ground. PQ Crypta is configured from a file. | ○ | ◑ | ◑ | ◑ | ● | ● | ◑ | ● | ○ |
| Scripting / plugin extension (Lua, WASM, Go modules) | ○ | ◑ | ◑ | ● | ● | ● | ● | ◑ | ◑ |
| Testing & Assurance | |||||||||
| HTTP/3 client conformance suite as a public service59 anomalies, each citing the RFC clause it exercises: reserved frame types, a duplicated SETTINGS identifier, a control stream opening with the wrong frame, an unknown QUIC frame, a Stateless Reset, a path-MTU black hole, refused 0-RTT. Every test binds its own UDP port because anomalies like supported_versions are endpoint-wide. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Verdicts asymmetric by class, with evidence required to failIgnoring a reserved HTTP/3 frame is a pass; ignoring a reserved QUIC frame is a failure, because QUIC reserves no ignorable frame types. A failure needs positive evidence the client read the anomaly — a rejection whose CONNECTION_CLOSE was lost looks exactly like no rejection, so silence is inconclusive, never a pass and never a fail. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Machine-readable catalogue, JSON reports and an SVG badge | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Conformance driver binary for third-party CIh3-conformance drives any client from a pipeline. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| QUIC impairment engine (telemetry wall)Six independent uni-streams at 20 Hz; a control stream accepts delay, loss, bandwidth cap, jitter and disconnect scoped to individual channels, leaving the others untouched — the opposite of TCP head-of-line blocking. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| QUIC versus TCP speed test serverDatagram RTT probing, stream download and upload throughput, packet-loss measurement, MTR hop traceroute with GeoIP city, ASN and country annotation. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| Black-box pentest suite in-tree32 scripts across 12 phases: WAF bypass and advanced evasion, bot detection, header injection, rate limits, TLS, HTTP smuggling, SSRF, cache poisoning, timing oracles, race conditions, XXE, SSTI, file upload, gRPC and AI/LLM attack surface. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| QPACK dynamic-table emission proof harnessEvery reachable HTTP/3 client advertises a QPACK table capacity of zero, which forbids the dynamic table outright — so both tests correctly report inconclusive against real clients and the emission behind them is never exercised. An unrun path is not a working path, and from outside a literal fallback is indistinguishable from a working dynamic table. So a client that grants what the specification requires is built in-tree, and the bytes on the wire are asserted. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
| 0-RTT reject proof harnesscurl does not attempt early data over HTTP/3, so every live run proves nothing. A resuming client is built in-tree: it sends early data, the server answers HelloRetryRequest, the socket counts the 0-RTT packets that arrived, and the request completes on 1-RTT keys. | ● | ○ | ○ | ○ | ○ | ○ | ○ | ○ | ○ |
CRS in the rule-count row means the proxy ships no rules of its own and relies on the
OWASP Core Rule Set through ModSecurity or Coraza. vendor means a proprietary rule set
supplied under licence. PQ Crypta's 256 rules are counted from the PQW-* identifiers in
src/waf.rs: 58 bot, 54 scanner-probe, 18 SQLi, 18 XSS, 16 SSRF, 16 traversal, 15 command injection, 12 deserialization, 9 SSTI, 8 anomaly, 7 JNDI, 6 LFI, 6 NoSQLi, 5 XXE, 3 CRLF, 3 prototype pollution, 2 GraphQL.
Where We Stand Alone
55 of the 228 rows are ones where PQ Crypta has a native implementation and no other proxy in the field has even a partial one. They cluster, and the clusters say something about what the product is for.
- Everyone credible now does X25519MLKEM768. That race is over, and it is not the hard part.
- ML-DSA-87 server certificates are the next step and nobody else has taken it. HAProxy says so in writing; Envoy's issue is closed as not planned; Go's
crypto/tlshas no ML-DSA, which rules out Caddy and Traefik at the language level. - PQC downgrade detection that reads the negotiated group off the connection rather than assuming it. A check that assumes the group cannot fire.
- ML-KEM-1024-sealed session tickets. A hybrid handshake protects the connection; everywhere else, the ticket that resumes it is classical.
- And the certificate machinery around it is not a lesser story: server-side ECH on TCP and QUIC with rotating HPKE keys published in DNS, ACME with HTTP-01 and DNS-01, External Account Binding for any CA, and Certificate Transparency submission after issuance — which nothing else in the field does at all.
- Multipath QUIC with concurrent data-carrying paths. No other proxy here negotiates the extension at all.
- WebTransport as a first-class server — sessions, streams and datagrams handled in-process, with origin validation and per-origin limits. Envoy passes extended CONNECT through; it does not run sessions.
- MASQUE CONNECT-UDP is the one place Envoy is also present.
- JA3/JA4 over QUIC. The ClientHello is inside encrypted Initial CRYPTO frames, so this needs the QUIC stack to surface it — which is why every fingerprinting implementation in the field is TCP-only.
- Runtime self-verification. A real TLS 1.3 handshake against the proxy's own config at startup, plus live probes of the WAF, rate limiter and mTLS. Anything not probed is labelled configured, never verified.
- A conformance suite that fails honestly. 59 anomalies; a failure needs positive evidence the client read it, because a rejection whose CONNECTION_CLOSE was lost looks exactly like no rejection.
- Proof harnesses for the untestable paths — QPACK dynamic table and 0-RTT reject, both driven by clients built in-tree because no real client exercises them.
- 32 black-box pentest scripts and a gating WAF regression corpus in the repository, not in a report.
- Bidirectional blocklist reconciliation. Unblocking in a dashboard has to actually lift the block; a sync that only ever adds is a support ticket generator.
- CORS headers on 429s, so a rate limit reads as a rate limit and not as a CORS bug.
- Health-check traffic excluded from metrics, so a one-second liveness probe does not own every percentile.
- Admin loopback enforcement that aborts startup rather than logging a warning nobody reads.
Where They Beat Us
A chart that only shows wins is an advertisement. These are the places another proxy is the better tool. Most are scope differences rather than deficiencies — PQ Crypta is a security-focused edge terminator, not a web server, a service mesh or an orchestrated ingress — but a scope difference still means you should pick the other one, so each is stated with what this proxy does instead.
- Breadth is level now: eleven algorithms each, ours adding power-of-two-choices, URI, query-parameter and header hashing and first-available to least-connections, round-robin, smooth weighted, random, IP hash and least response time.
- Stick-tables are a general keyed store an operator can build arbitrary tracking on. We ship the specific ones instead: rate-limit counters, affinity maps, the fingerprint corpus, blocklists, circuit state. Better out of the box, less to improvise with.
- The peers protocol syncs fleet state with no external dependency; our distributed limiting is Redis-backed, which is one more thing to run. Its runtime API also has far more surface than our 29 admin routes.
- And it is faster, measured rather than conceded. On one machine, same backend, same certificate, same worker count, and with our proxy configured to do what HAProxy’s configuration does and no more, HAProxy’s throughput over ours is 1.23× on HTTP/1.1, 1.38× on HTTP/2 and 1.18× on HTTP/3, and as deployed, with every feature on, 2.71× on HTTP/1.1, 3.10× on HTTP/2 and 1.70× on HTTP/3. Ours has the higher median in 5 of 18 cells. The distance is widest on HTTP/2. Figures and method are on the Method tab.
- What we have that it does not: canary splitting with sticky assignment and auto-rollback, traffic shadowing, and per-backend circuit-breaker overrides.
- FastCGI, uwsgi and SCGI. We speak HTTP over a Unix socket to PHP-FPM and do not implement the FastCGI wire protocol. This follows from scope — we serve no files of our own either — but if your backend speaks FastCGI, that settles it.
- A module ecosystem, mail proxying, and a stream module that makes it a general L4 balancer. Our L4 story is SNI passthrough and stops there.
proxy_cachehas fifteen more years of production behind it than ours.- What we have that it does not: a WAF in the binary rather than an end-of-life module, active health checks, cache purge and live blocklist control — all of which NGINX keeps behind the Plus licence or has retired.
- xDS. Envoy reconfigures continuously from a control plane at fleet scale. We load a file and watch it, which fits a fixed edge and not a mesh.
- gRPC is native there: transcoding, the gRPC health protocol, per-method routing. We carry gRPC over HTTP/2 and offer nothing gRPC-specific.
- WASM and ext_authz put someone else's logic in the data path. We have no extension mechanism — deliberately, since it is also an attack surface, but it is a capability we do not offer.
- What we have that it does not: the controls Envoy reaches for external services to provide — WAF, TLS fingerprinting, adaptive and distributed rate limiting — are in the binary, and post-quantum key exchange is not available to Envoy at all.
- Service discovery. Traefik reads Docker labels, Kubernetes CRDs and Consul. For a container fleet that is the entire job, and we do not do it.
- On-demand TLS. Caddy issues a certificate mid-handshake for a hostname it has never seen. Our ACME automation does not.
- Both start from a shorter file — a working edge here is a few dozen lines against Caddy's three, though the rest of its 434 settings carry defaults and need never be touched.
- What we have that they do not: ML-DSA-87 certificates and Certificate Transparency submission, which neither has at all, plus a WAF and TLS fingerprinting in the core binary rather than reached through a third-party plugin.
- This is the real gap, and the one no amount of implementation closes: a CVE history, third-party audits, distribution packaging, and an operator base measured in millions.
- LiteSpeed shipped HTTP/3 in 2019 and has served it at scale for longer than this project has existed.
- Everything here describes one release of a moving field. The evidence behind our column is published on the Method tab specifically so it can be re-checked rather than believed.
Who Should Pick What
Ten deployments, each with the proxy that fits it best and the one to reach for second. The runner-up column is the useful half: it says what you actually give up by taking it, which is usually the thing that decides an evaluation.
PQ Crypta is runner-up in most of the rows it does not win, and that is not courtesy — on the matrix it leads load balancing 68–60 on points and TLS 44–30, so on features it is the closest alternative even where another proxy is the right call. Where it is not the runner-up, the reason is specific and shows up in the matrix as one of the six rows it does not lead: FastCGI and uwsgi backends, dynamic service discovery, plugin and scripting extension, generic L4 stream proxying, gRPC-specific features, and on-demand TLS. Plus the two things a feature chart cannot hold — raw throughput, and the years behind a project.
| If your problem is… | Best fit | Why it wins | Runner-up | What you give up |
|---|---|---|---|---|
| The widest algorithm set, and raw throughput above all else | HAProxy | Ten-plus algorithms, stick-tables as a general primitive, peers-based state sync with no external dependency, and twenty years at the top of the throughput charts. | PQ Crypta | Measured on one machine against a like-for-like configuration, HAProxy’s throughput over ours is 1.23× on HTTP/1.1, 1.38× on HTTP/2 and 1.18× on HTTP/3 like for like, and 2.71× on HTTP/1.1, 3.10× on HTTP/2 and 1.70× on HTTP/3 as deployed. Ours has the higher median in 5 of 18 cells. The remaining distance is widest on HTTP/2. What you keep is the rest of the resilience surface, which leads this category on the matrix: per-backend circuit breakers, slow start, connection draining, request queuing, canary with auto-rollback and traffic shadowing. |
| Serving files, and FastCGI or uwsgi application backends | NGINX | It is a web server as well as a proxy. PQ Crypta is only a proxy: it serves no files of its own and does not speak the FastCGI wire protocol. | LiteSpeed | LSAPI beats FastCGI for PHP and the cache is built in, but the module ecosystem is far smaller and the open-source edition trails the commercial one. |
| A service mesh data plane driven by a control plane | Envoy | xDS reconfigures continuously at fleet scale, gRPC is native, and the entire mesh ecosystem is built around it. | HAProxy | Its Data Plane API and ingress controller get close, but it is not xDS-native, so you are outside the ecosystem every integration assumes. |
| Container ingress that configures itself | Traefik | Reads Docker labels, Kubernetes CRDs and Consul directly. There is no file to write and no reload to trigger. | Caddy | With the Docker and Kubernetes plugins it gets close and brings automatic HTTPS with it — but discovery is a plugin, not the core model. |
| HTTPS for hostnames you do not know in advance | Caddy | On-demand TLS issues a certificate mid-handshake for a host it has never seen. Note this is not ECH or ACME as such — PQ Crypta has both, and adds DNS-01, EAB and Certificate Transparency submission. | PQ Crypta | Neither of us issues mid-handshake, so if the hostnames are genuinely unknown this is Caddy's row outright. Once you can enumerate them, our ACME goes further: DNS-01, External Account Binding for any CA, and Certificate Transparency submission after issuance. |
| A mature, boring edge with the largest operator community | NGINX | Packaged in every distribution, documented everywhere, and a CVE and audit history measured in decades. This is the row PQ Crypta cannot win, and will not be able to for years. | HAProxy | Equally mature and arguably better engineered for balancing, but narrower — it is not a web server, so it rarely stands alone. |
| A quantum-resistant edge that must prove what it negotiated | PQ Crypta | ML-DSA-87 certificates, downgrade detection read off the live connection, ML-KEM-sealed session tickets, and a startup that verifies with a real handshake instead of announcing. | Caddy | X25519MLKEM768 on by default and automated ECH for the least possible effort — but Go's crypto/tls has no ML-DSA, there is no downgrade detection, and nothing verifies what is negotiated. |
| Terminating HTTP/3, WebTransport and MASQUE in one process | PQ Crypta | Multipath QUIC, native WebTransport sessions with origin validation and per-origin limits, CONNECT-UDP, and JA3/JA4 fingerprinting over QUIC. | Envoy | The only other proxy here that speaks CONNECT-UDP and extended CONNECT — but it passes WebTransport through rather than running sessions, and has no multipath and no QUIC fingerprinting. |
| A WAF, fingerprinting and adaptive rate limiting without a licence | PQ Crypta | 256 scored rules, JA3/JA4 on every transport including QUIC, adaptive baselines and Redis-distributed limiting — all in the core binary. | Caddy or Traefik | Coraza brings the OWASP Core Rule Set through a plugin at no cost, but you assemble it yourself, and neither gives you TLS fingerprinting or an adaptive baseline. |
| A zero-trust internal edge with per-route mTLS and signed requests | PQ Crypta | zero_trust_mode refuses to boot unless mTLS is required and no backend is plaintext; per-route HMAC proof-of-possession signs path and query with nonce replay prevention, and internal routes require a client certificate by default. |
Envoy | mTLS plus ext_authz can express all of it — but as a control plane and an external authorization service you build, run and keep available, rather than a config block. |
How Our Column Is Validated
The rule is that a description of the software is not evidence of it. Every entry is established
from an implementation in src/, from the configuration the production process is actually
running, or from a probe against the live endpoint — and each one is listed below with the check
that establishes it, so the column can be audited rather than taken on trust.
| Claim | How it is checked | Result |
|---|---|---|
| Hybrid PQC key exchange is live, not merely compiled in | openssl s_client -connect pqcrypta.com:443 -tls1_3 against the production edge | Negotiated TLS1.3 group: X25519MLKEM768 |
| The negotiated group is read, not assumed | FFI binding to SSL_get0_group_name in src/pqc_tls.rs:1379, reading the group off the live SSL* | Confirmed in code |
| The certificate chain is sent compressed (RFC 8879), on TCP and on QUIC | openssl s_client -tls1_3 -trace, which is the only mode that prints the CompressedCertificate message; handshake byte counts compared with and without -no_rx_cert_comp, seven connections each way | Compression type=zlib. Certificate message 3,435 → 2,376 bytes compressed (1.45:1), 2,384 on the wire once the eight-byte header is counted; handshake 4,921 → 3,870, a saving of 1,051 bytes on every connection. QUIC offers brotli and zlib, TCP zlib alone. Of four large external origins sampled, only Facebook also compressed |
| Server-side ECH is deployed and published | HPKE keypair and config list on disk at /etc/pqcrypta/ech-configs; dig HTTPS pqcrypta.com | Record carries ech=AD/+DQA7ZA… |
| ECH is a genuine server-side implementation | 817 lines at vendor/rustls-fixed/rustls/src/server/ech.rs; upstream rustls is client-side only to 0.23.42 | Confirmed in code |
| Multipath QUIC is negotiated, not aspirational | transport_config.max_concurrent_multipath_paths() at src/quic_listener/mod.rs:556, over the vendored noq stack | Confirmed in code |
| The WAF ships 256 rules with anomaly scoring | Counted the unique PQW-* identifiers in src/waf.rs; read the severity-to-score mapping | Exactly 256; Info 1 / Low 3 / Medium 5 / High 8 / Critical 10 |
| The conformance suite carries 59 anomalies | Counted unique test identifiers in src/conformance/catalog.rs; fetched /conformance/ | 59 IDs; endpoint returns 200 |
| MASQUE and the conformance vhost are enabled in production | Read /etc/pqcrypta/proxy-config.toml as the running process loads it | [masque] enabled = true, [conformance] enabled = true |
| The pentest suite is 32 scripts across 12 phases | Listed pentests/scripts/; counted the phases in run_all.sh | 32 scripts, 01–32 |
| NEL, Report-To and Server-Timing are actually emitted | curl -I https://pqcrypta.com/ | All three present, plus alt-svc: h3=":443", h3=":4434" |
| Alt-Svc varies by transport | curl --http2 -I vs curl --http3-only -I | Over TCP: h3=":443", h3=":4434". Over QUIC: h2=":443", h3=":4434" — the h3 entry for the port already in use is gone, since that is the connection rather than an alternative to it. Same two commands against the other vendors' own sites: HAProxy flips, Caddy sends no Alt-Svc over h3, NGINX and LiteSpeed repeat the h3 advertisement on both |
| Alt-Svc names only ports that actually answer | curl --http2 -I https://mail.fated.org/ | h3=":4434" and not :443, because inbound UDP/443 is filtered upstream of that host — server.alt_svc_ports overrides the derived list so a browser is never sent to a port that can only time out |
Capabilities the Config Never Surfaces
A configuration file is a poor inventory of what software does. Reading only config.rs
— 434 settings across 37 structures — misses a whole class of behaviour that has no knob
because it is not optional. Those entries come from reading the modules instead, and several are among
the strongest in the chart.
- 1,247 lines in
src/startup_verify.rswith no configuration surface at all. - Performs a real TLS 1.3 handshake against the proxy's own
ServerConfig, entirely in memory — no socket, no port, so it cannot be firewalled or collide with a bound address. - Then probes the WAF, the rate limiter, mTLS and every bound listener.
- Its stated rule: assert the capability, never the configuration. The hazard it closes is a real one across this whole product category — a startup banner that reads a version string off a subprocess is not reading the cryptography the server will actually serve. Measured here across three hosts loading three different libcryptos, the negotiated group is X25519MLKEM768 on all three, independent of what any of them reports at the command line. An operator reading a banner is being told the security boundary they deployed, so it had better come from the serving stack.
- Hop-by-hop header stripping before caching and forwarding — leaving these on produces
ERR_QUIC_PROTOCOL_ERRORon HTTP/3. - RFC 9110 HEAD semantics. A HEAD response carries the header fields a GET would. A proxy that instead synthesizes
content-length: 0from its own empty buffered body tells every link checker, uptime monitor and security scanner that a dynamic page is empty — so the length is taken from the backend, on all three transports. - Handshake facts stripped from the request before being set, so a caller cannot assert a TLS handshake it did not have.
- Log injection sanitisation and structural request-smuggling detection — neither has an off switch.
- Verified crawler checking (
crawler_verify.rs) — a declared crawler is treated as one only when its address is inside the ranges its operator publishes (Google, Bing, DuckDuckGo, Apple, OpenAI, Perplexity, Common Crawl, Ahrefs, refreshed every six hours), or, where the operator has a DNS contract, when reverse DNS and a forward confirmation agree. - The observed fingerprint corpus, persisted every five minutes and reloaded at startup. Explicitly evidence only: it never feeds classification or blocking.
- The QPACK and 0-RTT proof harnesses, which exist because no reachable client exercises those paths and an unrun path is not a working path.
- The impairment engine behind the telemetry wall — 940 lines of deliberate protocol misbehaviour with no config entry.
Benchmarked Against HAProxy
Throughput is measured head to head, on one machine, rather than asserted. What the harness controls for comes before the results, because a benchmark that publishes only its numbers is asking to be taken on trust — and each control below exists because its absence produces a confident wrong answer that looks exactly like a right one.
What the harness controls for
All three of these are invisible in the results themselves. They are caught by sampling the proxy, the backend and the generator in the same tick and reading them together; any one alone is consistent with the rig being fine.
The backend must not be the ceiling. This machine has twelve logical CPUs and
six physical cores — SMT siblings are paired, so a range like
“cores 0–1” is one core, not two. A backend given one core saturates it and
becomes the thing being measured: pinned that way, nginx tops out at
138,508 req/s with no proxy in the path at all, which is a figure HAProxy can
reach. The backend now gets two physical cores and four workers, and
bench-backend.sh measures its ceiling before every run so the proxies' numbers can be
read against it.
Both proxies must actually be on the cores they were given. taskset sets
a mask a process is free to overwrite, and HAProxy does: cpu-map re-pins its threads after
start. If that line and the harness disagree, one proxy silently gets more cores than the other inside
a benchmark whose entire output is a ratio — and the only symptom is a process reading above its
own CPU budget. Every thread's Cpus_allowed_list is asserted after each proxy starts, and
a mismatch aborts the run.
The generator must not be the ceiling either. h2load defaults to one
thread, and a single core cannot drive a proxy at this rate — pinned that way it saturates at
99 % while the server under test sits at 248 % of the 400 % available, and the cell
measures the client. It runs 6 threads across 3 physical cores, and its CPU is reported
alongside the server's for every published cell.
All three failures push the same way: a clipped backend, a clipped generator and an under-pinned opponent each understate whichever proxy is faster. On this rig that is not us, so every one of them would flatter these results. That is the reason they are controlled for rather than assumed away.
The rig
6 physical cores of an AMD EPYC-Milan Processor, split so that the only
saturated component is the one being measured: the backend gets 2 physical cores (nginx/1.28.3 (Ubuntu),
serving three fixed bodies from memory), the proxy under test gets 1 physical core and
2 workers, and
h2load gets 3 physical cores. Both proxies run as many workers as they have CPUs because HAProxy warns
that oversubscribing threads to CPUs degrades it severely, and benchmarking a product in a
configuration it warns about is not a comparison. One tool drives HTTP/1.1, HTTP/2 and HTTP/3
(h2load nghttp2/1.68.0, built from source against ngtcp2 and nghttp3), so the three protocols are
comparable to each other rather than to three different clients. HAProxy 3.2.9 with QUIC on
OpenSSL 3.5.5; same certificate, same backend, same TLS 1.3-only policy on both sides. The
configurations as run are published with the data:
HAProxy’s,
ours and
the backend’s.
Sampled every two seconds through every measurement, the only saturated component is the one being measured. HAProxy runs at a median 200 % of its 200 % and PQ Crypta at 199–200 %; the backend sits at a median of at most 116 % of its 400 % and the generator peaks at 183 % of its 600 %. Both are reported alongside every result rather than assumed. The backend's own ceiling, measured with nothing in front of it:
| Body | 10 connections | 100 connections |
|---|---|---|
| empty | 247,409 req/s | 361,378 req/s |
| 1 KB | 226,823 req/s | 335,438 req/s |
| 64 KB | 96,416 req/s (5.91 GB/s) | 123,928 req/s (7.59 GB/s) |
Results
HAProxy’s throughput over ours is 1.23× on HTTP/1.1, 1.38× on HTTP/2 and 1.18× on HTTP/3, as geometric means over the six cells of each protocol, with our proxy configured to do what HAProxy’s configuration does and no more. Above 1× HAProxy is faster; below it, we are.
As deployed, it is 2.71× on HTTP/1.1, 3.10× on HTTP/2 and 1.70× on HTTP/3: the same run, a third arm, our proxy with everything the production nodes run that one backend can exercise — the WAF in block mode, TLS-layer fingerprinting, both rate limiters, the security headers, compression, the optional features and post-quantum key exchange (its configuration). HAProxy does none of that here, so the distance between our two rows is what those features cost against a proxy doing none of them; the sections below take them one at a time.
| HAProxy over ours | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| Geometric mean over each protocol’s cells | 1.23× | 1.38× | 1.18× |
| Range across those cells | 1.00–1.45× | 0.99–1.81× | 0.92–1.61× |
| As deployed: geometric mean | 2.71× | 3.10× | 1.70× |
| As deployed: range | 1.88–3.40× | 1.89–4.13× | 1.09–2.35× |
Our arm (pqc-bench-lean.toml) is the plumbing configuration plus the four things
HAProxy’s own configuration does not do: no added response headers, no Alt-Svc on TCP, the
backend’s Server header passed through, no compression. A feature that is off
leaves the request path entirely rather than standing aside in it. Its responses are the same size as HAProxy’s to the byte — 229 bytes of headers over HTTP/1.1 and 225 over HTTP/2 on the 1,024-byte body the run checks before it starts. Same TLS 1.3 policy, the same 2 CPUs and 2 workers each. The key exchange, as the generator reported it for every run: HTTP/1.1, X25519 for HAProxy against prime256v1 for ours; HTTP/2, X25519 for HAProxy against prime256v1 for ours; HTTP/3, X25519 for HAProxy against prime256v1 for ours. All classical — the generator’s TLS library offers no ML-KEM group — and paid once per connection, which these runs hold open throughout.
At 64 KB the gap is 1.00–1.09× on HTTP/1.1, 0.99–1.00× on HTTP/2 and 0.97–1.13× on HTTP/3. Ours has the higher median in 5 of 18 cells, most clearly on HTTP/3 at 64 KB and a hundred connections, where a random run of ours beats a random run of HAProxy’s in 97 % of pairings. At empty and 1 KB bodies the gap is 1.28–1.45× on HTTP/1.1, 1.53–1.81× on HTTP/2 and 0.92–1.61× on HTTP/3: fixed per-request cost shows most where each request carries least. The architectural advantage behind that is real rather than a tuning gap — HAProxy carries no framework state per request.
The distance is widest on HTTP/2: 1.38× over its cells, and 1.81× on HTTP/2 at 1 KB and a hundred connections. That is where the next work is.
| Protocol | Body | Connections | HAProxy req/s | PQ Crypta req/s | HAProxy over ours | A run of ours is faster | As deployed req/s | HAProxy over as deployed |
|---|---|---|---|---|---|---|---|---|
| HTTP/1.1 | empty | 10 | 60,176 | 46,559 | 1.29× | 0 % | 19,669 | 3.06× |
| HTTP/1.1 | empty | 100 | 65,391 | 45,224 | 1.45× | 0 % | 19,449 | 3.36× |
| HTTP/1.1 | 1 KB | 10 | 57,423 | 44,763 | 1.28× | 0 % | 21,123 | 2.72× |
| HTTP/1.1 | 1 KB | 100 | 65,091 | 48,808 | 1.33× | 0 % | 19,171 | 3.40× |
| HTTP/1.1 | 64 KB | 10 | 22,319 | 22,327 | 1.00× | 50 % | 11,854 | 1.88× |
| HTTP/1.1 | 64 KB | 100 | 22,572 | 20,693 | 1.09× | 0 % | 10,125 | 2.23× |
| HTTP/2 | empty | 10 | 81,651 | 52,607 | 1.55× | 0 % | 22,449 | 3.64× |
| HTTP/2 | empty | 100 | 68,276 | 44,535 | 1.53× | 0 % | 16,905 | 4.04× |
| HTTP/2 | 1 KB | 10 | 78,845 | 48,520 | 1.63× | 0 % | 19,107 | 4.13× |
| HTTP/2 | 1 KB | 100 | 64,808 | 35,840 | 1.81× | 0 % | 15,859 | 4.09× |
| HTTP/2 | 64 KB | 10 | 19,919 | 20,070 | 0.99× | 56 % | 10,429 | 1.91× |
| HTTP/2 | 64 KB | 100 | 17,768 | 17,855 | 1.00× | 61 % | 9,410 | 1.89× |
| HTTP/3 | empty | 10 | 105,487 | 65,612 | 1.61× | 0 % | 44,900 | 2.35× |
| HTTP/3 | empty | 100 | 77,286 | 57,235 | 1.35× | 0 % | 34,246 | 2.26× |
| HTTP/3 | 1 KB | 10 | 66,686 | 72,205 | 0.92× | 69 % | 39,610 | 1.68× |
| HTTP/3 | 1 KB | 100 | 59,986 | 49,225 | 1.22× | 0 % | 31,182 | 1.92× |
| HTTP/3 | 64 KB | 10 | 14,736 | 13,093 | 1.13× | 0 % | 11,277 | 1.31× |
| HTTP/3 | 64 KB | 100 | 11,509 | 11,901 | 0.97× | 97 % | 10,550 | 1.09× |
Each figure is the median of 6 measurements of 60 seconds after a 15-second warm-up. “A run of ours is faster” is the share of pairings, one run of ours against one of HAProxy’s in the same cell, in which ours served more requests per second. HTTP/1.1 runs one request per connection at a time, HTTP/2 and HTTP/3 ten streams per connection. Both proxies get 1 physical core and 2 workers, so the absolute figures are a function of that budget; the ratios are what the page is about. Every row is in the published run, with the measurement windows and the CPU samples the figures above are computed from.
Why sixty seconds. Measured rather than asserted: the same cell (HTTP/1.1 at 64 KB and 10 connections), 6 runs of each proxy at each window, alternating. After a 2-second warm-up, 10-second runs of ours vary with a coefficient of variation of 8.2 % against 4.9 % at 60 seconds after 15 (HAProxy’s: 2.4 % and 0.9 %), and the short window reads our proxy 3.4 % higher than the long one while moving HAProxy 1.7 %. The runs are published.
How the run was controlled. The arms were interleaved within every cell, with the order rotated on each repetition so that every arm spent one repetition in each position: a null test on this rig once measured the second slot 3.7 % slower than the first, which is larger than some of the differences reported here, and rotation cancels it — median throughput by position came out at 0.996–1.003. Each proxy ran at a median 199–200 % of its 200 % CPU while the generator peaked at 183 % of 600 % and the backend at 116 % of 400 %, so the only saturated component was the one being measured. The backend was measured on its own beforehand and tops out at 96,000–361,000 req/s depending on the cell — in every cell at least 2.35× the rate the faster proxy reached with the same body and connection count. No request failed; the generator’s 2xx and completed counters differ by up to 0.04 % on any run, which is its accounting of the streams in flight when the window closes rather than an error from either proxy.
Which build. Proxy commit 6d75ab9, binary
fa8556d2a7508f05, measured 25 September 2026. The build serving this page is 5ec548c75203d03a. Its changes since, outside the conformance suite and the tools:
30f819aTCP listeners rebuild their rustls configs, ECH included, after every TLS reloada6369f3waf.programmatic_client_paths: the path form of allow_programmatic_clients1a6d5a8Routes can admit scripts without admitting scanners: allow_programmatic_clientsa4a1794Data files reload in place: the weekly refresh no longer restarts the proxy6f31427OpenSSL 4.0 accepted as native ML-KEM; --validate runs the security checksc562485Hot reload rebuilds the OpenSSL listener's TLS contextsda34358Post-quantum ECH (X-Wing, ML-KEM) and curveSM2MLKEM768b4020a2JA4 as the FoxIO spec defines it: SNI and ALPN out of part c50eb31eDependencies forward: quinn-proto 0.11.19, redis 1.7 with rediss://, notify 8, axum-server 0.86477091Formatting: tls.rs test imports in rustfmt order5daa632ML-DSA-87 certificate signing in FIPS builds too649d332Tests: PqdsaKeyPair::to_pkcs8v1 (aws-lc-rs 1.18 deprecates to_pkcs8)459557eML-DSA signing from aws-lc-rs's stable signature moduleb5a8420Response headers: one policy for every transport; a route's own headers stand9ff1915Hot reload applies to everything it can, and names what it cannot7456fc5QUIC endings ranked once for both servers; a TCP-only host over HTTP/3 gets 4216cfdd45Every listener routes on one canonical pathfba9e2eA redirect keeps the rest of a mixed-case path7ee0295Nonces are built from their source, never from a zeroed bufferbfb8f53WebTransport: forward the request's api_key as X-API-Keyfbed24c[pqc] experimental_groups: provider KEMs offered between ML-KEM and classicalee24321tls: pure ML-KEM-1024 leads the level-5 tuple, as the CNSA 2.0 TLS profile requiresfdcd878tls: ECH over TCP -- ClientHellos naming this server's ECH config go to rustls7a2b020tls: an ML-DSA-87 companion certificate per domain, for clients that accept only ML-DSA96dcc68tls: post-quantum before classical whatever the client lists first; ML-KEM-1024 preferred7ba5cf8Crawlers: verified by their operators' published ranges; the edge bans spare them7769babWAF: SQLI-005 matches stacked statements, not keyword prefixes; blocks say where they matched4d5435cConnection rate counts connections, not requests; Rust 1.99 clippy2daca29Backend pool: an idempotent request on a connection closed unanswered goes againb0e4e6bCredentialed programmatic clients pass the generic-client UA rules on routes that allow themb09497bx-health-check-bypass is proxy-only; clean peer closes are not errors46ce2b6SECURITY: proxy-only headers stripped from clients; bypass header no longer skips UA rules; WAF explain27139fcfuzz: one mutation fuzzer for every parser of raw client bytes, replayable by seed310fb2eSECURITY fingerprint: an odd cipher-suite length no longer aborts the proxy; ClientHello parser fuzzeda9949b0SECURITY fingerprint: JA4 ALPN field per spec; a crafted ALPN value no longer aborts the proxy296433ewaf: PQW-JNDI-007 requires OGNL syntax; no rule may match random base649ddd89fshutdown: live connections finish and close, the WebTransport server is told, and a signal during startup is helda8f5cfbdeps: jsonwebtoken 11, OpenTelemetry 0.33, tower-http 0.7, webpki-roots 1.0, reqwest 0.13; HTTP/3 and WebTransport now actually join the caller's trace783ddccwebtransport: a session no longer holds the origin map's lock; the whole proxy could be stopped by five connections3f05e53tls: 0-RTT on every TCP listener, served before the handshake completes
What else ran. 11 events on the rig host during this run overlapped a measured cell; each is in the run’s disturbances file with the cells it touched. Every cell here is the median of its repeats, so one disturbed repeat moves it only if it is the middle value.
| When (UTC) | What ran | Cells it overlapped |
|---|---|---|
| 25 Sep 14:44:43 | copy of harness scripts to the source tree (read only) | HTTP/1.1, 1 KB, 100 connections: pqc-full, repeat 1 |
| 25 Sep 14:44:55–14:45:03 | copy of harness scripts to the source tree (read only) | HTTP/1.1, 1 KB, 100 connections: lean-now, repeat 2 |
| 25 Sep 14:45:50 | update export scripts (file copy) | HTTP/1.1, 1 KB, 100 connections: lean-now, repeat 2 |
| 25 Sep 15:21:48–15:21:51 | staged build 1b6eafe9: copy + on-node --validate | HTTP/1.1, 64 KB, 10 connections: lean-now, repeat 5 |
| 25 Sep 15:21:52–15:22:22 | production pqcrypta-proxy restarted | HTTP/1.1, 64 KB, 10 connections: lean-now, repeat 5 |
| 25 Sep 15:26:43 | install export-study and queue-window (file copies) | HTTP/1.1, 64 KB, 10 connections: haproxy, repeat 6 |
| 25 Sep 15:53:15–20:42:15 | register a CI runner for pqcrypta-configs (hard links, config only) (no end logged) | 216 cells: HTTP/2 and HTTP/3 × empty, 1 KB and 64 KB bodies × 10 and 100 connections; haproxy, lean-now, pqc-full, repeats 1–6 |
| 25 Sep 17:43:48–17:43:51 | staged build eb2366fe: copy + on-node --validate | HTTP/2, 64 KB, 10 connections: lean-now, repeat 4 |
| 25 Sep 17:43:52–17:44:22 | production pqcrypta-proxy restarted | HTTP/2, 64 KB, 10 connections: lean-now, repeat 4 |
| 25 Sep 20:11:12–20:11:15 | staged build 020cc631: copy + on-node --validate | HTTP/3, 64 KB, 10 connections: lean-now, repeat 5 |
| 25 Sep 20:11:16–20:11:46 | production pqcrypta-proxy restarted | HTTP/3, 64 KB, 10 connections: lean-now, pqc-full, repeat 5 |
HTTP/3 with one request in flight
Throughput at high concurrency is the easy case. The demanding one is a single request at a time down each connection — one in-flight stream, which is what a browser fetching a single resource actually looks like, and where any per-request latency shows up as a throughput number rather than hiding behind pipelining.
What governs it is when the acknowledgement leaves. A client will not start its next
request on a connection until the previous one has been acknowledged, so an acknowledgement that waits
is a request that waits. We acknowledge a request in the response that answers it: an owed ACK rides on
the next packet the connection sends for any other reason (server.ack_piggyback, on by
default), and server.ack_eliciting_threshold — default 1, RFC 9000’s
“every other packet” — decides only when an acknowledgement is worth a packet of its own.
Each alternative has a cost that shows at one stream. Same binary, one setting changed per arm, the arms
interleaved and rotated, on the second machine (1 KB body, ten connections, one stream each;
build 93256d064498e33d, 24 September 2026):
| Acknowledgement policy | req/s | What the client waits for |
|---|---|---|
| Threshold 1, no piggybacking | 383 | max_ack_delay, 25 ms, on every request |
| Threshold 0 — acknowledge every packet at once | 13,149 | a separate ACK-only datagram per request |
| Threshold 1 with piggybacking (default) | 14,805 | nothing: the response carries it |
Without piggybacking the first row is a timer, not a distribution: its repetitions agree to three significant figures. Acknowledging every packet at once removes the wait but spends a datagram on it; at a hundred connections of ten streams that is 0.05 more received and 0.05 more sent per request (0.61 against 0.55), because at that load most acknowledgements have a response to ride on anyway. Two things this is not. It is not the ACK-frequency extension, which governs what we ask the peer to do and cannot affect our own acknowledgements. And it is not stream credit: ten times the advertised stream limit moves this cell’s median by -4.1 %, inside the spread of its own repetitions (71 %). The runs and the configurations are published.
What an HTTP/3 request costs, and where
Measured the way optimisation work on this proxy is measured — instructions retired per request, which is stable to a fraction of a per cent where throughput is not — with HAProxy 3.2 on the same second machine as the reference: an empty body at a hundred connections of ten streams costs us about 107,000 instructions per request against HAProxy’s 89,000 (runs). Four mechanisms decide most of that:
| Mechanism | What it is worth | Its share of a request today |
|---|---|---|
| Padding read as a run. A client that pads its datagrams sends a thousand-odd one-byte PADDING frames with every request; they are consumed in one step, not decoded one frame at a time. | 19.4 % of instructions per request on the current build, against the same source decoding one frame per byte | — |
Responses that finish together leave together. A woken connection
yields once, past the runtime’s next I/O poll, before it transmits
(server.quic_send_coalescing). Otherwise each response leaves in its own packet
and the client answers each with its own. |
1.82× throughput at a hundred connections on the current build, against the setting off | — |
| Headers without per-request work. QPACK Huffman coding by table in both directions, response fields encoded straight from the header map, and the values fixed for a connection — Alt-Svc, client address, handshake facts — built once per connection. | 19 % of instructions per request when made (65bc9fe, 23 September 2026) |
9.5 % of an HTTP/3 request’s instructions: QPACK and every header builder |
| One backend client, pooled per backend. Connections are returned when a body has been read to its end; no task is spawned per request to return them. | 5–7 % of instructions per request, on every transport when made (518d10d, 24 September 2026) |
Its own code: 2.3 % on HTTP/3, 1.1 % on HTTP/2, 1.2 % on HTTP/1.1 |
The two rewrites cannot be measured as “the current code without them” — there is no
faithful way to put the old code back into today’s — so they carry the figure measured when
each was made, beside what that path costs today, from an instruction profile of the
deployed commit (701e003) built with symbols
(shares).
The packet counts are the clearest picture of what separates the two proxies now. Per request, at a hundred
connections, HAProxy’s event loop and its clients exchange about 0.31 datagrams; we exchange
about 0.55, and 2.42 without coalescing
(runs). Beyond that the cost is spread thin: the largest single function in an HTTP/3 request is
quic_listener::QuicListener::handle_h3_request at 5.6 % of its instructions.
Consistency, and how precise any of this is
We are markedly less repeatable than HAProxy. Spread within a single unchanged cell (fastest of the 6 measurements over slowest) is a median of 5.6 % for HAProxy, 17.2 % at worst, against 15.0 % for us, 41.1 % at worst. It is not a pattern of degradation: throughput does not decay under sustained load on either side.
The cause is not the proxy doing more work. Sampling per second under load (HTTP/2, 1 KB bodies, 10 connections of 10 streams), throughput sits in a slow mode near 129,000 packets/s or a fast one near 187,000, holding one for as long as 100 seconds at a stretch. Across the two modes the CPU is saturated either way, cycles per second are no lower in the slow mode (7.57 against 7.15 billion), and instructions retired per packet are flat to 0.04 % — 43,539 against 43,520. The code executes the same work; the processor retires it 1.54× more slowly, instructions per cycle falling from 1.14 to 0.74.
Four explanations were tested and rejected. CPU steal on the proxy’s CPUs is exactly zero in every one of 100 seconds while throughput moved between 121,000 and 223,000 packets/s, so the hypervisor is not taking cycles. dTLB misses run at 2.0 per packet in the slow mode against 1.7 in the fast, beside some 59,000 cycles per packet: too few to matter whatever each one costs, so it is not huge pages. Frequency scaling cannot be it while cycles per second are no lower in the slow mode. Moving the two workers off shared SMT siblings (CPUs 4-5) onto separate physical cores (4,6) raised throughput 5 % (43,000 against 40,900 req/s) and widened the scatter rather than narrowing it (a coefficient of variation of 14.5 % against 10.7 %).
The one counter that moves with the mode is cache misses: 804 per packet in the slow mode against
676 in the fast, 19 % more for the same instructions. The code does the same
work and finds less of it in cache; what else is using the cache is not visible from inside this machine, which is a virtual one.
The probes are published per second, with
steal and the two layouts, measured
25 September 2026 on build 93256d064498e33d.
The work is stable even where the rate is not, so development measures the work.
Instructions retired per request, counted over the generator’s own measurement window, has a
coefficient of variation of 0.77 % against req/s at 17.4 %
over the same 6 runs. For a 1 % change in the code to stand two standard errors clear, an A/B needs
5 rounds by instructions — about 9 minutes — and
2,425 by throughput. Throughput is what this page publishes, because throughput is what an operator gets, but it is a
poor instrument for deciding whether a change made the code cheaper. benchmarks/instr-ab.sh in the source tree is the
one used for that, and the runs are published.
How precise are the numbers above? Less than a table of five-digit figures suggests. That scatter is the floor on what any single cell of ours can resolve, which is why every cell is a median of 6 interleaved measurements and the gap is quoted as a geometric mean over each protocol’s cells rather than from any one of them. Treat a movement smaller than 15 % in any single figure of ours on this page as the harness rather than the software, and one as large as 41 % as possible in the worst cell.
Where the allocations are not
What closes the distance is where the allocations are not, and each decision behind that is held to an interleaved A/B of two of our own binaries under an identical generator, where a shared ceiling cancels: four hot structures passed as reference-count bumps rather than copies, route regexes compiled once at startup rather than per request per route, a direct request dispatch, and the allocator.
When they were made, the last two carried more than the rest combined, and for the same reason. The allocator is mimalloc,
not glibc’s: on the current build it is worth 1.19× on HTTP/1.1, 1.07× on HTTP/2 and 1.14× on HTTP/3 in
throughput against the system allocator, on the second machine, and it was worth
1.74× on the benchmark rig when made (b23bd75, 4 September 2026). glibc locks per arena, and a proxy allocates small
buffers constantly then frees them on whichever worker finished the request, the case that design serves
worst. Requests dispatch directly rather than through the web framework’s router, which otherwise
rebuilds a boxed, heap-allocated routing table on every request for a table with one entry in it: worth
1.35× on HTTP/2 when made (0b5eb48, 4 September 2026); today dispatch’s own code is
2.49 % of an HTTP/2 request’s instructions and the framework router
0.00 %. Dispatch is a TCP path and does not touch HTTP/3; the allocator does.
The pattern is worth naming, because it is the one thing here that generalises. A profiler attributes
an allocation’s own instructions to the allocation and everything it really costs — lock
contention, cache traffic — somewhere else. So the decisions that remove allocations
return far more than their profile share predicts, while putting the shared security state behind a
reference count — 3.4 % of the profile, and plainly correct — measured
1.00× when made (6050eb2, 4 September 2026), and cloning it costs
0.00 % of a request today. It stands because it is right, not because it helps.
Each multiplier is what that change is worth against the binary without it, on the machine named. None of them says where the proxy stands against HAProxy — that is the table above. The current-build A/Bs are published, with the variants’ hashes in the manifest.
What the optional features cost
Early hints, priority hints, request coalescing and Server-Timing — the four settings
pqc-bench-features.toml turns on over the
plumbing configuration — cost
1.14× on HTTP/1.1, 1.13× on HTTP/2 and 1.03× on HTTP/3, as geometric means over each protocol’s six cells:
the plumbing arm’s throughput over the features arm’s. They are not HTTP/3-only despite the
name of the middleware that implements them, which sits in the TCP chain as well as the QUIC one.
Per cell the cost runs 1.07–1.24× on HTTP/1.1, 0.98–1.37× on HTTP/2 and 0.98–1.10× on HTTP/3. The range for HTTP/1.1 sits entirely above 1.00×, so the effect there is not an artefact of one cell; within one unchanged cell the run’s repetitions spread a median 18.5 % and at worst
76.8 %, which bounds how finely any single figure here resolves. Measured on build
fa8556d2a7508f05
(commit 6d75ab9), 25 September 2026, with
the two arms interleaved and rotated as in the comparison, 4 measurements of 60 seconds each;
no request failed. The
rows are published with the run. Also on the rig during it: 2 events, listed below.
| When (UTC) | What ran | Cells it overlapped |
|---|---|---|
| 25 Sep 22:46:20–22:46:53 | production proxy deploy 783ddcc (WebTransport origin-lock fix) | HTTP/2, 64 KB, 100 connections: plumb, repeat 3 |
| 25 Sep 22:46:20–22:46:50 | production pqcrypta-proxy restarted | HTTP/2, 64 KB, 100 connections: plumb, repeat 3 |
What the security stack costs
Both arms run the same binary and the same generator, and both bind
10.99.0.1, a non-loopback address
— which is what makes the comparison mean anything, because is_trusted()
short-circuits the entire per-request security block for loopback peers. Measured over loopback, the
security stack is switched out from under the request and the figure is zero by construction.
On the on arm (its configuration, against the off arm’s) the WAF’s 256 rules run in block mode, TLS-layer JA3/JA4 fingerprinting classifies every connection, replay and drift detection run, and both rate limiters are active. What is lifted is only their power to ban: the thresholds are set far above the offered load, so the figure is the cost of evaluating a request rather than the cost of refusing one. A run that gets itself banned measures the rejection path, which is fast and beside the point. The harness refuses to start unless a plain GET returns 200 on both arms, a SQLi probe returns 403 on the on arm and 200 on the off arm, and a five-second burst at a hundred connections returns no 4xx on either. In this run the probe drew 403 and 200, and the bursts served 104,214 and 143,543 2xx with 0 4xx. Then all 144 measurements behind the table completed with no failed request, and every one’s 2xx count matches its completed count to within 0.03 %.
| Protocol | Security off | Security on | Cost | Per-cell range |
|---|---|---|---|---|
| HTTP/1.1 | 31,826 req/s | 24,187 req/s | 1.36× | 1.21–1.71× |
| HTTP/2 | 34,410 req/s | 29,867 req/s | 1.39× | 1.15–1.73× |
| HTTP/3 | 55,943 req/s | 43,797 req/s | 1.19× | 1.04–1.28× |
req/s columns are the 1 KB, ten-connection cell; the cost column is the off arm’s
throughput over the on arm’s as a geometric mean over each protocol’s cells, median of
4 interleaved measurements each. No cell is excluded.
The security stack costs 1.36× on HTTP/1.1, 1.39× on HTTP/2 and 1.19× on HTTP/3 for 256 WAF rules in block mode, full JA3/JA4 fingerprinting with replay and drift detection, and two rate limiters: most on HTTP/2, least on HTTP/3. Every protocol’s per-cell range sits clear of 1.00×, so the effect is real on all three transports rather than an artefact of one cell.
Measured on build fa8556d2a7508f05
(commit 6d75ab9), 25 September 2026, the two arms
interleaved and rotated as in the comparison, 4 measurements of 60 seconds each. Within one
unchanged cell the repetitions spread a median 18.0 % and at worst
49.7 %, which bounds how finely any one figure here resolves. One structural difference
beyond the security work itself: TLS-layer fingerprinting needs its own accept loop to see the ClientHello, so enabling it changes
which listener serves the connection. The rows are published with the run. Also on the rig during it: 4 events, listed below.
| When (UTC) | What ran | Cells it overlapped |
|---|---|---|
| 26 Sep 01:16:12–01:16:46 | production proxy deploy 9ddd89f (shutdown drain fix) | HTTP/2, empty, 100 connections: sec-off, repeats 2–3 |
| 26 Sep 01:16:12–01:16:43 | production pqcrypta-proxy restarted | HTTP/2, empty, 100 connections: sec-off, repeats 2–3 |
| 26 Sep 02:05:19–02:05:25 | production proxy deploy 296433e (WAF PQW-JNDI-007 fix) | HTTP/3, empty, 10 connections: sec-off, repeat 1 |
| 26 Sep 02:05:19–02:05:22 | production pqcrypta-proxy restarted | HTTP/3, empty, 10 connections: sec-off, repeat 1 |
Nagle, and why TCP_NODELAY is on
The proxy sets TCP_NODELAY on every accepted client-facing socket, and the
fingerprinting stream wrapper forwards poll_write_vectored and
is_write_vectored so a response leaves as a single segment rather than a header
segment followed by a body segment. Both matter for the same reason.
Nagle’s algorithm will not put a short segment on the wire while earlier data is unacknowledged. A proxy writes a response and then waits, so when a response ends in a short segment behind a full one, the short one is held. The client has already sent its next request and so has nothing to piggyback an acknowledgement on, and its delayed-ACK timer — 40 ms on Linux — has to expire first.
The setting is server.tcp_nodelay, and it defaults to on. One binary, the knob
as an operator would set it (on,
off), the arms alternating with the order flipped each round:
| Load | TCP_NODELAY | Nagle | Nagle over TCP_NODELAY |
|---|---|---|---|
| HTTP/2, 64 KB, 10 connections × 1 stream | 15,765 req/s at 617 µs | 241 req/s at 41.4 ms, server CPU 5 % | 0.015× |
| HTTP/1.1, 64 KB, 10 connections × 1 stream | 20,191 req/s at 493 µs | 452 req/s at 22.1 ms, server CPU 7 % | 0.022× |
| HTTP/2, 64 KB, 10 connections × 10 streams | 17,854 req/s at 5.62 ms | 23,707 req/s at 4.13 ms | 1.33× |
| HTTP/2, 64 KB, 100 connections × 10 streams | 15,417 req/s at 64.8 ms | 18,650 req/s at 53.6 ms | 1.21× |
| HTTP/2, 1 KB, 10 connections × 10 streams | 40,465 req/s at 2.56 ms | 2,392 req/s at 41.8 ms, server CPU 14 % | 0.059× |
With it off, 3 of the 5 loads stall — the server’s CPU falls to 5–14 % because it is sitting on the client’s timer rather than working, and throughput falls by a factor of 17–65. Where the mean latency sits below 40 ms, only some requests wait: at HTTP/1.1, 64 KB, 10 connections × 1 stream, request times with Nagle run from 146 µs to 42.5 ms around a mean of 22.3 ms — the same timer, hit by about 52 % of requests — against a mean of 502 µs without it (both distributions). Where it does not stall, Nagle’s coalescing is worth 1.33× at HTTP/2, 64 KB, 10 connections × 10 streams and 1.21× at HTTP/2, 64 KB, 100 connections × 10 streams. A throughput gain on some saturated multi-stream loads against a stall on HTTP/2, 64 KB, 10 connections × 1 stream, HTTP/1.1, 64 KB, 10 connections × 1 stream and HTTP/2, 1 KB, 10 connections × 10 streams is not a close call for anything interactive, which is why the default is what it is; the setting exists for bulk transfer, where the trade may go the other way.
Measured 26 September 2026 on build fa8556d2a7508f05,
3 rounds of 60 seconds per arm and load; medians. The
rows are published. TCP_NODELAY governs every accepted socket whatever else is
configured, so none of this is specific to the security path; what varies is the write pattern, and so the body size and stream count at which
the exposure shows up.
HTTP/3 has no equivalent exposure. QUIC has no Nagle: the stack decides when a packet leaves, and an ACK it owes rides on the next packet the connection sends for any other reason, as described under “HTTP/3 with one request in flight” above.
What post-quantum key exchange actually costs
The question a reader has is “what does PQC cost me?”, and the honest answer is a measurement. Both proxies here run OpenSSL 3.5.5 — ours with its OpenSSL provider (its configuration, against HAProxy’s) — which carries ML-KEM natively, so this is a head-to-head on the same key exchange rather than one product’s post-quantum against another’s classical. The figure is the mean connect time over 200 concurrent fresh handshakes, one request per connection; the proxies alternate within every repetition with the order rotated, 8 repetitions per group, and the group is read back off a connection before each is measured rather than assumed from the configuration — every one negotiated the group it was offered. secp384r1 is the classical baseline because it is the only classical group our listeners offer; X25519 on its own is not among them.
| Proxy | secp384r1 (classical) | X25519MLKEM768 (hybrid) | SecP384r1MLKEM1024 (hybrid) |
|---|---|---|---|
| HAProxy 3.2.9 | 51.9 ms | 38.5 ms | 63.9 ms |
| PQ Crypta | 50.0 ms | 30.8 ms | 66.0 ms |
| Ours over HAProxy’s | 0.96× | 0.80× | 1.03× |
| A repetition of ours is faster | 58 % | 73 % | 41 % |
Adding ML-KEM to the same curve — secp384r1 to SecP384r1MLKEM1024 — costs 1.23× on HAProxy and 1.32× on ours: that is the like-for-like price of the post-quantum half. X25519MLKEM768, the hybrid browsers send, is faster than secp384r1 alone on both (0.74× and 0.62×): its classical half is X25519, which is cheaper than P-384 by more than ML-KEM-768 adds. Against HAProxy, our handshakes are faster on secp384r1 and X25519MLKEM768 and slower on SecP384r1MLKEM1024, most clearly on X25519MLKEM768, where a repetition of ours beats one of HAProxy’s in 73 % of pairings; on secp384r1 and SecP384r1MLKEM1024 the repetitions overlap too much for the medians to say which is faster. The handshake is dominated by cryptography both products get from the same library, which is why the two are close here; the request-throughput gap measured above — 1.23× on HTTP/1.1, 1.38× on HTTP/2 and 1.18× on HTTP/3 — is our own per-request path. Absolute figures are inflated by 200-way concurrency and are only meaningful as ratios.
Measured 25 September 2026 on build 93256d064498e33d.
The repetitions are published, with the group each one negotiated.
What a feature matrix cannot tell you. Seven things it scores as present, which
only load says anything about. The proxy raises RLIMIT_NOFILE at startup — at the
stock descriptor limit a proxy that does not returns 100 % 5xx while the process
looks healthy. Nagle is off on backend connections, worth 917 µs of mean latency.
ip_hash hashes consistently rather than hash % len, so one backend
failing does not re-home nearly every client. fingerprint.enabled = false is
honoured on QUIC as well as TCP. Route regexes compile once rather than per request per route, and
SecurityState and the three HTTP clients are shared rather than cloned per request
— those last three are 40 % of all CPU when they are not.
An eighth, of the same class as the fourth:
[rate_limiting] enabled = false disables the connection rate limiter too,
rather than leaving a separate flag on at ten connections per second banning for five minutes on the
first offence. Switching the section off switches off all of it. This kind of gap is only visible off
loopback, where the per-request security block is short-circuited before any of it is reached —
which is its own lesson about where you point a benchmark.
How Their Columns Are Sourced
Competitor values come from each project's own material: official documentation first, then release notes and vendor engineering blogs, then the issue tracker where a feature's absence is the thing being established. Where a maintainer has stated a position plainly it is quoted rather than inferred — HAProxy's blog says post-quantum signatures are step two and not yet supported; Envoy's post-quantum issue is closed as not planned pending a BoringSSL upgrade; NGINX's ModSecurity WAF reached end of life in March 2024. No competitor cell is set from a benchmark article or a third-party comparison post.
Known Limits of This Comparison
- Breadth is not quality. The scorecard counts whether a capability exists. It says nothing about throughput, latency, memory behaviour, or how a feature holds up under an adversary. On raw performance HAProxy and NGINX have decades of evidence and we have months.
- We grade ourselves. Our column is established from source and a live endpoint, which is a stronger basis than a description — but the person who chose which 228 rows to include is the person who wrote the software. A row that exists partly because we have it is still a row that flatters us.
- Editions blur. Splitting NGINX and HAProxy into free and paid columns is fairer than one column each, but a large operator running Plus or Enterprise sees a different chart than the one an open-source evaluator sees.
- Documentation lags code. A competitor cell marked absent means the project does not document it. Several of these projects move quickly, and QUIC-adjacent features in particular are shipping continuously.
- One operating point. The throughput figures are one machine, one physical core per proxy, two workers each, a loopback path and a static backend. Ratios measured there need not hold at eight cores, across a real network, or against a slow origin. A wrong detail in a rig produces confident wrong numbers, so re-run it rather than trusting it: the harness, its guards and the reasons for each of them are in
benchmarks/in the source tree. - One snapshot. Everything here reflects PQ Crypta Proxy 0.2.2 as deployed in October 2026, and each competitor at the version named in its column header.
References
- HAProxy Technologies. How to enable post-quantum cryptography and TLS termination with HAProxy — source for the PQC curve, edition and signature rows.
- HAProxy. Post-Quantum Cryptography (PQC) Support · Issue #3171.
- nginx. Support for QUIC and HTTP/3 — source for 0-RTT, QUIC Retry, GSO and the supported SSL libraries.
- nginx. Module ngx_http_acme_module; Let's Encrypt, Native ACME Support Comes to NGINX.
- NGINX Community. Post-Quantum Cryptography support in NGINX and Encrypted Client Hello Comes to NGINX.
- F5. F5 NGINX ModSecurity WAF Is Transitioning to End-of-Life — end of life March 2024.
- Envoy. BoringSSL upgrade for Post-Quantum Cryptography support · Issue #42893 (closed as not planned) and Support for TLS ECH · Issue #44165.
- Caddy. Release notes — 2.10 introduced automated ECH and x25519mlkem768; tls directive documentation lists x25519mlkem768 as the only PQC curve.
- Traefik. Post-Quantum Key Exchange support for HTTPS · Issue #10755; OCSP documentation.
- LiteSpeed. QUIC and HTTP/3 Support.
- IETF. draft-ietf-quic-multipath, RFC 9298 (CONNECT-UDP), RFC 9297 (HTTP Datagrams), RFC 9111 (HTTP Caching), RFC 9218 (Extensible Priorities).
- Certificate compression sources. nginx,
ssl_certificate_compression— introduced in 1.29.1, defaultoff; HAProxy Technologies, Announcing HAProxy 3.4 —tune.ssl.certificate-compression, default auto; Envoy, 1.38.0 release notes — brotli added to QUIC and brotli with zlib to TCP TLS; Go, crypto/tls: add support for Certificate Compression (RFC 8879) · Issue #42967 — open, milestone Unplanned, which is the source for the Caddy and Traefik cells; LiteSpeed, SSL Performance. - PQ Crypta. pqcrypta-proxy — the source tree every value in our column is read from; proxy documentation, multipath QUIC whitepaper, HTTP/3 conformance suite.