The next version of HTTP moves from TCP to QUIC, a transport with its own reliability built on UDP. The payoff is that packet loss on one stream no longer stalls the whole connection the way it can on TCP with HTTP/2.

Why TCP was the ceiling

HTTP/2 multiplexes many streams over one TCP connection, but TCP itself only knows a single ordered byte stream. When a packet is lost, TCP retransmits and requires in-order delivery, so every stream waiting on that connection halts until recovery completes. That is the head-of-line block HTTP/2 inherits from its transport. The fix is not more TCP tuning but a different transport: give each stream its own reliable, independently-ordered delivery, so a loss in one stream does not stall its neighbors. This is the specific ceiling HTTP/3 is built to remove.

QUIC changes the transport, not the semantics

HTTP/3 keeps the ideas HTTP/2 introduced, multiplexing, header compression, and stream priority, but runs them over QUIC, which lives on UDP and owns its own reliability. QUIC implements per-stream flow control and loss recovery, carries its own encryption, and can migrate a connection when the network path changes, all features TCP ties to the connection. From an application’s point of view the semantics barely change: same methods, headers, and status codes. What moves is the way the bytes are protected, and where connection repair can happen, which is what lets one lossy stream stop dragging the whole page.

When it matters most

Per-stream recovery pays for itself on lossy paths: poor mobile coverage, congested last-mile links, and lightly packet-lossy networks, exactly the places where a single stalling stream used to hold an entire page hostage. It is also a negotiated upgrade, enabled when both the client and server agree, and falling back to HTTP/2 without any action from the site operator where QUIC is unavailable. Rolling forward is therefore gradual and compatible; the deployment risk is low, and the benefit concentrates exactly where users feel it, on unreliable connections.

When it breaks

QUIC still rides on UDP, and some networks treat that as suspicious. Enterprise firewalls and older middleboxes may throttle or drop UDP, or pass small DNS-sized datagrams while discarding larger QUIC packets. The symptom is rarely a clean error. Alt-Svc advertises HTTP/3, the client attempts QUIC, and the handshake times out before falling back to HTTP/2 over TCP. If the fallback timer is too generous, users feel the upgrade as a regression, so track handshake success by network type and keep TCP ready.

0-RTT resumption is a second trap. Replaying the ClientHello saves a round trip, but anything accepted as early data can be replayed by an observer. RFC 9001 therefore limits early data to replay-safe operations. Sending a non-idempotent POST in 0-RTT risks executing the same order twice. Servers must reject risky early data or add anti-replay handling.

Connection migration has its own edge. QUIC identifies a connection by ID rather than the four-tuple, so a phone moving from Wi-Fi to cellular can continue without a new handshake. A NAT rebinding looks similar, however, and a stale NAT binding or a load balancer that hashes on addresses can send return packets nowhere. Endpoints must validate the new path before trusting it.

Finally, do not promise that QUIC removes every queue. It removes transport-level head-of-line blocking between streams, but connection-wide congestion control still applies. A saturated bottleneck delays all streams together. Per-stream recovery isolates loss, not overload.

Check it quickly

Save the following as quic_streams.py in a scratch directory and run python3 quic_streams.py. It models four streams of ten packets each with one packet lost on stream zero, then compares an idealized QUIC scheduler against an idealized TCP scheduler. No sockets are opened; the numbers are a scheduling calculation, not a wire capture.

STREAMS = 4
PACKETS = 10
LOST_STREAM = 0
RETRANSMIT_PENALTY = 3


def quic_completion():
    """Each stream recovers on its own: only the lossy stream pays."""
    completions = []
    for stream in range(STREAMS):
        if stream == LOST_STREAM:
            completions.append(PACKETS + RETRANSMIT_PENALTY)
        else:
            completions.append(PACKETS)
    return completions


def tcp_completion():
    """One ordered byte stream: a single loss blocks every stream."""
    blocked_at = PACKETS + RETRANSMIT_PENALTY
    return [blocked_at] * STREAMS


def main():
    quic = quic_completion()
    tcp = tcp_completion()
    # Only the stream that lost a packet waits for retransmission.
    assert quic[LOST_STREAM] == PACKETS + RETRANSMIT_PENALTY, quic
    assert all(t == PACKETS for i, t in enumerate(quic) if i != LOST_STREAM), quic
    # TCP cannot release later bytes until the loss is repaired.
    assert all(t == PACKETS + RETRANSMIT_PENALTY for t in tcp), tcp
    quic_blocked = sum(1 for t in quic if t > PACKETS)
    tcp_blocked = sum(1 for t in tcp if t > PACKETS)
    assert quic_blocked == 1, quic
    assert tcp_blocked == STREAMS, tcp
    print(f"QUIC completion: {quic} (blocked streams: {quic_blocked})")
    print(f"TCP  completion: {tcp} (blocked streams: {tcp_blocked})")
    print("PASS: loss stalls one QUIC stream but all TCP streams")


if __name__ == "__main__":
    main()

The QUIC schedule completes the three healthy streams at time ten while only the lossy stream waits until time thirteen. The TCP schedule completes every stream at time thirteen because the single ordered byte stream cannot release later bytes until the loss is repaired. The script asserts that isolation, then prints both vectors. The model ignores congestion and reordering; it isolates loss recovery so the difference is visible in one run.

Further reading

Sources