The Runtime Theory
Networking

UDP and QUIC: When TCP Is the Wrong Transport

Why TCP's byte-stream ordering causes head-of-line blocking, and how QUIC fixes it — connection IDs, packet numbers, per-stream reliability, and 0-RTT.

The Runtime Theory Team3 min read#udp#quic#tcp#http3#transport
On this page

TCP is a byte stream with a guarantee: the receiver delivers bytes in exactly the order the sender wrote them. That guarantee is the foundation of the web — and it's also TCP's Achilles heel. When one packet is lost, TCP must hold back every byte that arrived after it until the gap is repaired, because delivering out of order would break the stream. This article is about what happens when you don't need that guarantee, and the modern answer to "which transport should I actually use?" — which is neither classic TCP nor raw UDP, but QUIC.

The head-of-line blocking you already live with

Every multiplexed protocol on top of TCP inherits this problem. HTTP/2 puts many concurrent streams on one TCP connection — but the byte stream underneath doesn't know about streams. One lost packet stalls every stream on the connection:

text
packets:  [1] [2] [3 LOST] [4] [5] [6]
receiver:  ✓   ✓   wait...   ✗   ✗   ✗
        stream A needed packet 3 → blocked
        stream B needed packet 4 → blocked
        stream C needed packet 6 → blocked

Only after the sender retransmits packet 3 (detected via duplicate ACKs, ~one RTT) does anything move. On a 100 ms RTT mobile link, that's 100 ms of silence for every stream per lost packet — and cellular links lose ~1-3% of packets. This is why "HTTP/2 over cellular" can feel worse than the old six-socket HTTP/1.1.

What UDP actually is

UDP is not "unreliable." That's the wrong mental model. UDP is connectionless: each datagram is self-contained, there's no stream, no ordering, no retransmission, and no congestion control — but everything those features do is implemented in software above the socket. The kernel-level contract is tiny: best-effort delivery of whole datagrams, checksum-verified.

The useful properties fall out of that:

  • No head-of-line blocking — every datagram is independent, so a lost one doesn't block its successors.
  • No setup — no handshake, so no RTT tax before the first byte.
  • No connection state in the kernel — the server doesn't track your flow.

The cost: if you need reliability, ordering, or congestion control, you build it yourself.

QUIC: TCP's features, rebuilt on UDP

QUIC (RFC 9000) is precisely that rebuild. It implements connection state, reliability, ordering, congestion control (reusing CUBIC/BBR), and TLS 1.3 — in user space, in the application library, not in the kernel. The kernel only sees UDP datagrams. Three structural consequences follow:

1. Per-stream reliability, not per-connection. QUIC keeps a separate sequence space (packet number) for the transport and independent byte offsets per stream. When a packet is lost, only the stream it carried stalls:

text
QUIC packets:  [1] [2] [3 LOST] [4] [5] [6]
stream A: needs 3 → blocked, retransmitted
stream B: needs 4 → delivered immediately ✓

2. Connection IDs instead of 4-tuples. TCP identifies a connection by (source IP, source port, dest IP, dest port). QUIC identifies it by an opaque connection ID chosen by the client. When your phone hops from Wi-Fi to cellular, the IPs change and the TCP socket dies — the QUIC connection survives because the ID does:

text
TCP:  (192.168.1.20:51234 → 104.21.62.13:443)   ← dead on IP change
QUIC: connection_id=0x7f3a...                   ← same on IP change

3. Packet numbers that actually mean something. In TCP, a retransmission keeps the same sequence number — the sender can't tell duplicates from reordered packets, the classic spurious-retransmission mess. QUIC gives every packet a unique packet number and never reuses it, so a retransmit is just a new packet. Loss detection and RTT estimation get dramatically cleaner — this alone made QUIC's recovery more accurate than TCP's.

0-RTT: the cost of a handshake disappears

TCP + TLS 1.3 is 2 RTTs before the first request byte. QUIC's first connection is 1 RTT — the TLS handshake is the transport handshake, merged into the first flight. And on a resumed connection, the client sends encrypted application data immediately, in the same packet as the Initial — zero round trips:

So when is TCP still right?

  • Long-lived, in-order bulk transfers (SQL sessions, file sync, log shipping): the stream abstraction and zero application code are worth the head-of-line cost.
  • Anything with NAT traversal and firewalls: UDP is aggressively filtered on some networks, and QUIC's fallback story is still "use TCP."

The deciding question is not "UDP or TCP?" — it's "do I need the kernel's stream abstraction, or can I own reliability at the application layer?"

The mental model

  • TCP's ordering guarantee is a feature that becomes a tax the moment you multiplex.
  • UDP is not unreliable; it's unmanaged — you own everything above the datagram.
  • QUIC rebuilds TCP's guarantees per-stream, survives IP changes, and removes the handshake tax — at the price of owning your transport code.
  • One lost packet tells you more about your transport choice than a year of benchmarks.