Comparisons of encrypted DNS protocols are usually written from the specifications. We run all four transports on one resolver, so we could put the same query through each of them and count what came back.

The measurements were taken in an isolated network namespace on a veth pair with a 1500-byte MTU, with 20ms of delay injected in each direction so that one round trip costs exactly 40ms and round trips are visible in the timings rather than inferred. One client was used for every transport, so the differences are protocols rather than implementations. Production traffic was not involved.

DoQ reaches an answer a full round trip before DoT and DoH, which is QUIC combining the transport and TLS handshakes. DoH3 is also QUIC and yet lands with the TCP transports. We first attributed that to HTTP/3 opening its control streams, then captured the handshake and found the real cause: this server runs QUIC address validation, so a new connection is answered with a 119-byte Retry packet and the client must send its entire ClientHello a second time. It is a setting on our resolver, not a property of HTTP/3.

Under 2% packet loss the medians hardly move on any transport. The tails do not stay together, and the number that separates them is one second. When TCP loses a packet during a handshake it cannot infer the loss from later packets, so it waits out a retransmission timeout whose floor RFC 6298 sets at one second. Over 300 lookups per transport the measurement lands on that floor exactly: DoT's 99th percentile is 1082ms above its own median, DoH's is 1078ms above its own, two independent transports agreeing to within four milliseconds. Lose a second packet and the timer doubles, which is what DoT's worst sample is - 3379ms. Neither QUIC transport went past 495ms. Plain DNS is fast in every sample that arrives, for the simple reason that it has no loss recovery at all: twelve of 300 lookups never returned.

The transport that fails worst under loss is our own DoH3, and chasing it down led to a bug in nginx. Fifteen of its 300 lookups returned no answer, a worse rate than unprotected DNS, and every one is a handshake that never completed. The post-quantum ServerHello does not fit in a single packet: ML-KEM's 1088-byte key share pushes it to roughly 1178 bytes, so it splits across two QUIC Initial packets. Lose one and the client holds half a ServerHello and cannot derive handshake keys. Recovering from that is routine - except nginx does not. Its own debug log shows the server detecting the loss and deciding to retransmit, then sending eight further Initial packets that contain nothing but acknowledgements until the connection closes. The client behaves correctly throughout.

It reproduces on a completely stock nginx built from unmodified source, with two independent client stacks, and needs one further ingredient: a certificate chain long enough to span several Handshake packets. We have reported it upstream as nginx/nginx#1616 and published packet captures, the debug log and a scripted reproduction at nginx-quic-initial-repro. Anyone running nginx with HTTP/3 and post-quantum key exchange is affected.

The result we did not expect concerns the post-quantum key exchange. On DoQ the post-quantum handshake completes a full round trip faster than the classical one. A QUIC server may not send more than three times the bytes it has received from an address it has not validated. A classical ClientHello fits in one 1308-byte packet, so the server may send 3924 bytes; it has 4131 bytes to send, stops 207 bytes short of finishing, and has to wait for the client to speak again. The larger post-quantum ClientHello needs two packets, the budget doubles, and the whole flight goes out at once. The lever is handshake size rather than cryptography, so a shorter certificate chain removes the stall - which makes this our result rather than a general rule. Both packet traces are in the guide.

The full write-up carries the tables, the charts and the method, including its limits: one vantage point, one client stack, and no DNSCrypt endpoint here, so DNSCrypt stays out of the measured tables.

The harness is public. The namespace setup, the measurement script and the raw output from this run are at github.com/Ozy-666/dns-transport-bench, so any of this can be checked or repeated against another resolver. The code is BSD 2-Clause and the measurements are CC BY 4.0.