QUIC and HTTP/3 Deep Dive: Handshake, Streams, Loss Recovery and Congestion Control (2026)

QUIC and HTTP/3 Deep Dive: Handshake, Streams, Loss Recovery and Congestion Control (2026)

QUIC Protocol Deep Dive: Handshake, Streams, Loss Recovery and Congestion Control

Most engineers meet the QUIC protocol as a checkbox in a CDN dashboard: flip “HTTP/3” to on, watch a graph move. Underneath that checkbox sits a complete replacement for the transport layer the web has used since the 1980s. QUIC merges the cryptographic handshake with the transport handshake, moves loss recovery and congestion control into user space, and gives every request its own independently delivered stream.

This matters in 2026 because HTTP/3 is no longer experimental. W3Techs reported it in use on roughly 41% of all websites in October 2026, and every major browser negotiates it by default. Yet a surprising number of production incidents still come from teams that treat QUIC as “TCP with a different packet format”.

You will leave with a working mental model of the wire protocol, the arithmetic of why it saves round trips, the exact loss-recovery constants from RFC 9002, and a clear view of the cases where HTTP/3 gives you nothing.

What this covers: the RFC family, why UDP, the 1-RTT and 0-RTT handshakes, packet and frame structure, connection IDs and migration, streams and head-of-line blocking, loss detection, congestion control and flow control, deployment, observability, limits, and MASQUE.

Context and Background

For three decades, a web request meant a stack of three separate negotiations. TCP established a reliable byte stream with a three-way handshake. TLS then negotiated keys on top of it, costing one or two further round trips depending on the TLS version. Finally HTTP sent its request. Each layer was designed independently, and each hid its internals from the others.

HTTP/2 (2015) multiplexed many requests onto a single TCP connection, which fixed the connection-count problem of HTTP/1.1 but created a new one. TCP delivers bytes strictly in order, so a single lost segment stalls every stream sharing the connection until retransmission arrives. On clean wired networks this barely matters. On lossy mobile links it can make HTTP/2 slower than six parallel HTTP/1.1 connections.

Google began experimenting with QUIC around 2012 and deployed it in Chrome and its own services. The IETF took the work over, and the result is a family of standards published in May 2021. The core documents are:

  • RFC 8999: version-independent properties of QUIC (the only things guaranteed never to change).
  • RFC 9000: the transport protocol itself, covering connections, streams, packets, frames and flow control.
  • RFC 9001: how TLS 1.3 is embedded in QUIC to derive packet protection keys.
  • RFC 9002: loss detection and congestion control.
  • RFC 9114: HTTP/3, the mapping of HTTP semantics onto QUIC streams.
  • RFC 9204: QPACK, the header compression format that replaces HPACK.
  • RFC 9369: QUIC version 2, which exists mainly to exercise version negotiation and resist ossification.

Two companion specifications matter for the later sections. RFC 9221 adds unreliable datagrams to QUIC, and RFC 9298 defines proxying UDP over HTTP (the CONNECT-UDP method used by MASQUE). The IETF working group also standardised an HTTP datagram framework in RFC 9297.

If you want the cryptographic half of this story in depth, our guide to the TLS 1.3 handshake, 0-RTT and mTLS on constrained IoT devices covers key schedules and resumption in more detail than this post can. The transport-level view here assumes you know what a TLS 1.3 handshake accomplishes. For the authoritative text, the QUIC transport specification in RFC 9000 is unusually readable for a standard.

The QUIC Protocol Reference Architecture

QUIC is a secure, multiplexed, connection-oriented transport carried in UDP datagrams, with TLS 1.3 handshake integrated into the transport rather than layered on top. It provides reliable delivery per stream, encrypted headers, connection migration and user-space loss recovery, and it carries HTTP/3 as its main application.

QUIC protocol architecture showing HTTP/3 and QPACK above QUIC transport, TLS 1.3, loss recovery and UDP

Figure 1: How the QUIC protocol stack fits together. TLS 1.3 is not a layer under QUIC but a component inside it, supplying keys to the packet protection machinery.

Figure 1 shows the structural difference from the classic stack. In TCP, TLS is a separate library that treats TCP as a pipe. In QUIC, the TLS 1.3 handshake messages travel inside QUIC CRYPTO frames, and QUIC extracts the secrets TLS derives to encrypt its own packets. TLS never sees QUIC packets, and QUIC never sees TLS records. RFC 9001 defines this interface precisely.

The long-description version of the figure: HTTP/3 and QPACK sit on top and use QUIC streams. QUIC transport owns streams, flow control, connection IDs and acknowledgements. Loss detection and congestion control hang off the transport. Everything is packed into UDP datagrams and handed to IP. MASQUE and unreliable datagrams plug in at the HTTP/3 and QUIC layers respectively.

Why UDP, and why not a new IP protocol

The honest answer is deployability. A new protocol number above IP would be dropped by most NATs, firewalls and load balancers, a problem the SCTP experience made painfully clear. UDP passes nearly everywhere, and a UDP header adds only 8 bytes.

Using UDP also lets QUIC live in user space, so a CDN can ship a transport improvement by deploying a new library version instead of waiting years for operating-system kernels to update. That is the real strategic argument. TCP’s deployment cycle is bound to kernels, middleboxes and hardware offloads, which is why changes like TCP Fast Open stalled.

The cost is that QUIC inherits nothing from the kernel. It must implement reliability, congestion control, pacing and timers itself, and it must pay the CPU price of crossing the user-kernel boundary for every datagram. We return to that cost later.

Encrypted by default, and almost entirely opaque

QUIC encrypts almost everything. The packet payload is always protected, and so is most of the header. Only a small part stays visible to the network: the first byte’s flags, the version, connection IDs on long-header packets, and a few bits on short-header packets.

This is a deliberate anti-ossification measure. TCP’s visible fields allowed middleboxes to rewrite sequence numbers, strip options and enforce assumptions, and those behaviours froze the protocol. Encrypting the transport state means a middlebox cannot depend on it. The side effect, which operators feel acutely, is that passive monitoring tools can no longer see retransmissions, RTT or stream boundaries. The spin bit, a single optional header bit, exists to give on-path observers a coarse RTT signal.

A single connection, many paths of identity

A TCP connection is identified by the four-tuple of source and destination addresses and ports. If the client’s IP address changes, the connection dies. QUIC instead identifies a connection by connection IDs, opaque byte strings chosen by each endpoint. We examine how that enables migration shortly.

The Handshake: 1-RTT, 0-RTT and What Actually Happens

A QUIC connection starts with a cryptographic handshake that doubles as the transport handshake. A new connection to a server needs one round trip before the client can send application data, versus two or three for TCP with TLS. A resumed connection can send data in the very first flight, known as a 0-RTT connection, at the cost of replay exposure.

Sequence diagram of the QUIC handshake with Initial, Handshake and 1-RTT packets and 0-RTT resumption

Figure 2: The QUIC 1-RTT handshake. Note that the client can send its HTTP/3 request in the same flight as its Handshake Finished message.

Packet number spaces and encryption levels

QUIC does not use a single sequence of packet numbers. It uses three separate packet number spaces: Initial, Handshake and Application Data. Each corresponds to an encryption level with its own keys, and acknowledgements are tracked separately per space.

Initial packets are protected with keys derived from the client’s chosen destination connection ID and a version-specific salt. These keys give no secrecy, since anyone can derive them, but they do give integrity against accidental corruption and off-path injection. Handshake packets use keys derived from the TLS handshake secrets. 1-RTT packets, the long-running application data, use keys from the final TLS application secrets.

This separation is why a QUIC implementation tracks loss and RTT per space during the handshake, and why RFC 9002 treats max_ack_delay as zero for Initial and Handshake spaces.

The 1-RTT flow, step by step

The client sends an Initial packet carrying a CRYPTO frame with the TLS ClientHello. RFC 9000 requires that any UDP datagram carrying an Initial packet be padded to at least 1200 bytes. The padding does two jobs: it proves the path supports a reasonable datagram size, and it limits the amplification an attacker can extract using spoofed source addresses.

The server answers with an Initial packet containing the ServerHello, then Handshake packets carrying EncryptedExtensions, its certificate chain and Finished. At this point the server can already derive 1-RTT keys, though it cannot yet trust that the client is who it claims to be.

The client verifies the certificate, sends its own Handshake Finished, and immediately follows with 1-RTT packets containing the first HTTP/3 request. One round trip has elapsed between the client’s first byte and the moment it can send application data. The server confirms the handshake with a HANDSHAKE_DONE frame, after which both sides discard Handshake keys.

Address validation and the amplification limit

Before the server has validated the client’s address, RFC 9000 forbids it from sending more than three times the number of bytes it has received from that address. That limit is the 3x anti-amplification rule. It exists because UDP source addresses are trivially spoofed, and without it QUIC servers would be attractive reflection amplifiers.

This rule has a practical consequence. A server certificate chain of 4 to 5 KB cannot be sent in response to a single 1200-byte Initial: 3 x 1200 is 3600 bytes. The server must wait for further client datagrams, which may cost an extra round trip. Large certificate chains therefore hurt QUIC handshakes more than TCP handshakes. RFC 8879 certificate compression, supported by several TLS stacks, is a common mitigation. The limit is lifted once the server validates the address, either through a successfully processed Handshake packet from the client or a Retry token.

A server under attack can use the Retry mechanism: it replies with a stateless Retry packet containing a token, and the client must echo that token in a fresh Initial. This costs one extra round trip but removes any server-side state for unvalidated clients. NEW_TOKEN frames let a server hand out tokens on a prior connection so returning clients skip this step.

0-RTT: faster, but replayable

When a client resumes a previous session, it can attach early data to its first flight using keys derived from the earlier session’s resumption secret. For HTTP/3 this means the first request leaves the client alongside the ClientHello and the response can arrive after one round trip total.

The catch is replay. An attacker who captures the 0-RTT packets can send them again, and a server without defences may process the request twice. TLS 1.3 offers no protection against this inside a single server instance, and anti-replay across a fleet requires shared state or strict single-use tickets.

The practical rule, spelled out in RFC 8470 for HTTP, is that 0-RTT data should carry only idempotent requests. A GET for a static image is fine. A POST that creates an order is not. Servers can signal this by sending the Early-Data header to origins, and origins can reject non-safe requests with a 425 (Too Early) status. Our TLS 1.3 post on constrained devices discusses the ticket and replay-window trade-offs from the IoT side.

0-RTT also lacks forward secrecy for the early data itself, since it is encrypted with a key derived from a long-lived secret. That is the second reason to keep it for low-risk requests.

Worked RTT arithmetic

Abstract claims about “fewer round trips” mean little without numbers. Take a client 80 ms round trip from a server, and ignore DNS, server think time and transmission delay. These figures are illustrative arithmetic, not measurements.

Scenario Round trips before first response byte Time at 80 ms RTT
TCP + TLS 1.2, new connection 1 (TCP) + 2 (TLS) + 1 (request) = 4 320 ms
TCP + TLS 1.3, new connection 1 + 1 + 1 = 3 240 ms
QUIC 1-RTT, new connection 1 (handshake) + 1 (request) = 2 160 ms
QUIC 0-RTT, resumed 1 (request in first flight) 80 ms
TCP + TLS 1.3 with early data, resumed 1 + 1 = 2 160 ms

Three observations follow. First, the headline saving of QUIC over a modern TCP plus TLS 1.3 stack is one round trip, not three. The big wins in the folklore are measured against TLS 1.2. Second, the saving scales with RTT: at 10 ms the difference is 10 ms and probably invisible, while at 300 ms on a poor mobile link it is 300 ms and clearly felt. Third, the first visit to a site is often not QUIC at all, because the client learns about HTTP/3 support only after a first TCP connection, as the Alt-Svc section below explains.

For a page that opens connections to five distinct origins, the savings multiply, though browsers parallelise those handshakes, so the wall-clock gain is closer to one round trip than five.

Deeper Analysis: Packets, Streams, Loss Recovery and Congestion Control

Packet and frame structure

A UDP datagram carries one or more QUIC packets, a technique called coalescing, which is how the Initial and Handshake packets of a server’s first flight share a datagram. Each packet carries one or more frames. The packet header comes in two shapes. Long headers, used during the handshake, carry the version and both connection IDs. Short headers, used for the rest of the connection, carry only the destination connection ID, a packet number and a few flag bits.

Packet numbers are encrypted via header protection, a second layer of masking applied after payload encryption, so an observer cannot track them. Packet numbers also never repeat within a space: a retransmission goes out in a new packet with a new number, carrying copies of the lost frames. This removes TCP’s retransmission ambiguity, where an ACK for a retransmitted segment cannot be matched to the original or the copy, and makes RTT sampling cleaner.

The frame types you will meet most often:

Frame Purpose
CRYPTO Carries TLS handshake bytes
STREAM Carries application data for one stream, with offset and optional FIN
ACK Acknowledges received packet numbers as ranges, plus an ack delay
MAX_DATA, MAX_STREAM_DATA, MAX_STREAMS Flow-control credit updates
NEW_CONNECTION_ID, RETIRE_CONNECTION_ID Connection ID management
PATH_CHALLENGE, PATH_RESPONSE Path validation for migration
CONNECTION_CLOSE Terminates the connection with an error code
DATAGRAM Unreliable payload from RFC 9221

Integers use a variable-length encoding in which the top two bits of the first byte select a length of 1, 2, 4 or 8 bytes. That keeps small values compact, which matters because frame headers are overhead on every packet.

ACK frames deserve attention. They carry a largest-acknowledged value, a first range, and then alternating gap and range pairs, so a receiver can describe an arbitrarily fragmented set of received packets compactly. Because packet numbers are never reused, the ACK range structure is unambiguous. TCP’s SACK option, limited to roughly three or four blocks by option space, is a pale comparison.

Connection IDs and connection migration

Each endpoint issues connection IDs that the peer must use as destination on packets sent back. A connection ID can be up to 20 bytes in QUIC version 1, and endpoints issue several via NEW_CONNECTION_ID frames. The active_connection_id_limit transport parameter tells the peer how many it will hold; RFC 9000 requires a value of at least 2.

When a phone moves from Wi-Fi to cellular, its source address changes. The server sees packets arriving from a new address with a known connection ID. It sends a PATH_CHALLENGE to the new address, and the client answers with PATH_RESPONSE, proving it can receive there. Meanwhile, the connection continues, and congestion state is reset for the new path, since the old window says nothing about the new link.

Privacy is the reason for multiple IDs. If a client reused one connection ID after migrating, an on-path observer could link the two network identities. RFC 9000 therefore requires switching to an unused connection ID when migrating to a new path. Migration is initiated by the client; servers only respond. Note also the separate feature of a server preferred address, which lets a server steer clients to a different address after the handshake.

Connection IDs have a major operational consequence for load balancers. A load balancer hashing on the four-tuple will send a migrated flow to the wrong backend, and the connection breaks. The solution is to encode routing information into the server-chosen connection IDs. The IETF QUIC-LB work defines schemes for this, and large operators such as Google and Meta use their own variants, with eBPF or dedicated layer-4 balancers. If you run QUIC behind a generic NLB, you either get four-tuple stickiness, which breaks migration, or you need CID-aware routing. We discuss edge routing more broadly in our CDN architecture guide.

Streams and the end of transport-level head-of-line blocking

A QUIC stream is an ordered, reliable byte sequence within a connection. Streams are cheap: opening one requires no round trip, only sending a STREAM frame with a new stream ID. A stream ID is a 62-bit integer, and its two low-order bits encode who initiated it and whether it is bidirectional: 0x0 for client bidirectional, 0x1 server bidirectional, 0x2 client unidirectional and 0x3 server unidirectional. That is why HTTP/3 request streams are numbered 0, 4, 8, and so on.

Comparison of TCP with HTTP/2 head-of-line blocking against QUIC independent streams in HTTP/3

Figure 3: QUIC vs TCP on loss. One lost segment stalls every HTTP/2 stream on a TCP connection, while a lost QUIC packet delays only the streams whose frames it carried.

Figure 3 captures the key difference. With HTTP/2 over TCP, the kernel hands the application a single ordered byte stream. If segment 7 is lost and segments 8 to 20 arrive, the kernel holds 8 to 20 back until 7 is retransmitted, even if they belong to unrelated responses. QUIC delivers each stream’s data as soon as that stream’s own bytes are contiguous. A lost packet that carried only stream 4’s data stalls stream 4 and no other.

This is a precise claim, and it has limits. If a lost packet carried frames for three streams, all three wait. If loss is rare, nothing is gained, because nothing stalls. The benefit grows with loss rate and with the number of concurrent streams, and it is concentrated in the tail of the latency distribution rather than the median. Published measurements from large deployments consistently show improvements at high percentiles on poor networks and near-parity on good ones.

QUIC also separates stream-level from connection-level flow control. A receiver advertises a limit per stream (MAX_STREAM_DATA) and an aggregate limit (MAX_DATA), plus a cap on how many streams may be opened (MAX_STREAMS). These credits exist so a slow consumer cannot force the sender to flood its memory. A stream blocked on flow control triggers a STREAM_DATA_BLOCKED frame, giving diagnostic visibility that TCP does not offer.

HTTP/3 and QPACK

HTTP/3 maps each request-response exchange onto one client-initiated bidirectional stream. A set of unidirectional streams carries control data: one control stream for SETTINGS and GOAWAY, and two QPACK streams for the header compression state. Frames inside HTTP/3, such as HEADERS and DATA, are distinct from QUIC frames and live inside stream payloads.

HTTP/2’s HPACK compression assumed a single ordered stream, so header blocks could depend on table updates in strict order. Carried over unchanged to QUIC, this would reintroduce head-of-line blocking at the header level. QPACK solves this by moving table updates to a dedicated encoder stream and making each header block explicit about which table state it requires. A decoder that has not yet received the needed update blocks that request only. The SETTINGS parameter QPACK_BLOCKED_STREAMS bounds how many requests may wait, and an encoder choosing to reference only acknowledged entries avoids blocking entirely, at the cost of compression ratio. This is a real trade-off, not a free lunch: aggressive dynamic-table use improves bytes on the wire but risks stalls.

Loss detection

RFC 9002 defines two ways to declare a packet lost, plus a timer for when nothing is acknowledged at all. Figure 4 shows how the pieces connect.

Flowchart of QUIC loss detection with packet threshold, time threshold, PTO and congestion controller reaction

Figure 4: QUIC loss recovery flow. ACK processing drives RTT updates and loss declarations, a probe timeout covers the silent case, and the congestion controller consumes both signals.

The first method is the packet threshold: a packet is lost when a packet sent at least kPacketThreshold later has been acknowledged. The recommended value is 3, matching TCP’s classic three duplicate ACKs. The second is the time threshold: a packet is lost if it was sent long enough ago relative to an acknowledged later packet. The recommended multiplier kTimeThreshold is 9/8 of the larger of the smoothed and latest RTT, with a floor at the 1 ms timer granularity.

The third mechanism is the probe timeout (PTO), which replaces TCP’s separate retransmission timeout and tail loss probe. The PTO is computed as the smoothed RTT plus the larger of four times the RTT variance or the granularity, plus the peer’s max_ack_delay. When it fires, the sender transmits one or two ack-eliciting probe packets, and critically it does not by itself reduce the congestion window. The PTO doubles on each consecutive expiry, exponential backoff in the manner of TCP.

Before any RTT sample exists, RFC 9002 recommends an initial RTT of 333 ms. That is conservative by design. A connection resumed from a prior session may seed its estimate from remembered values, which is one reason 0-RTT flows tend to recover faster in the first seconds.

If probes also fail and two ack-eliciting packets spanning a long enough interval are lost with nothing acknowledged between them, the sender declares persistent congestion. The duration threshold is the PTO-style quantity multiplied by kPersistentCongestionThreshold, recommended as 3. The response is drastic: the congestion window collapses to the minimum of two datagrams.

One refinement worth knowing is ACK delay handling. Receivers report how long they held a packet before acknowledging it, scaled by an ack_delay_exponent transport parameter (default 3), and senders subtract this from RTT samples, bounded by the peer’s declared max_ack_delay (default 25 ms). This yields cleaner RTT estimates than TCP timestamps allow, but it trusts the peer’s reported delay, so implementations clamp it.

Congestion control in QUIC

QUIC congestion control is not a single algorithm. RFC 9002 specifies a NewReno-style controller as a baseline that every implementation can follow, but states plainly that endpoints may use others. The recommended initial window is ten times the maximum datagram size, capped at the larger of 14,720 bytes or twice the datagram size. The minimum window is twice the datagram size. Growth is slow start until the first loss or exit, then congestion avoidance.

In practice, deployments choose their controller independently in user space. Cubic, long the Linux TCP default, is available in most stacks, including the Cloudflare quiche library and Google’s implementation. BBR, which estimates bandwidth and minimum RTT rather than reacting to loss, is used by Google in its QUIC deployment and is configurable in several open-source stacks. Treat any claim about which operator uses which algorithm on which traffic as something to verify against that operator’s current documentation; these choices change.

The comparison that matters for readers is this. QUIC congestion control is a contract about fairness and signals, not a specific curve. A QUIC flow competing with a TCP Cubic flow at the same bottleneck should take a similar share if it uses Cubic, because the response function is the same. A QUIC flow using BBR behaves differently: it can claim a larger share against loss-based flows in shallow buffers, and the BBRv1 literature documented fairness problems that BBRv2 and later versions aim to address. The behaviour depends on version, so test before assuming.

Because QUIC is in user space, three practical advantages follow. Operators can ship a congestion controller change in a normal deployment. Pacing is straightforward, since the stack controls send timing and can spread a window over an RTT instead of bursting. And per-connection state can be instrumented freely. The disadvantage is that every implementation is another chance for a bug, and a buggy sender can be unfair to the whole network. The IETF interoperability and fairness testing culture around QUIC mitigates this but does not eliminate it.

ECN, explicit congestion notification, is supported in QUIC through ACK frames that report ECN counts, and RFC 9002 specifies that a congestion-experienced mark triggers a response like a loss. The L4S work on scalable congestion control builds on this, but deployment depends on network support that is uneven.

Flow control, stream limits and the idle timeout

Flow control and congestion control are separate. Congestion control protects the network, and flow control protects the receiver’s memory. A common tuning mistake is to enlarge only the connection-level window and forget the per-stream window, leaving a single large download throttled well below the path capacity. High bandwidth-delay-product paths need windows at least as large as the product of bandwidth and RTT. At 1 Gbit/s and 100 ms, that is 12.5 MB of in-flight data.

The max_idle_timeout transport parameter lets each side declare when to silently close an idle connection. The effective value is the lower of the two peers’ values. Keep it in mind when debugging “connections vanish after 30 seconds” tickets, because NAT bindings for UDP often expire faster than for TCP, and a mismatch between the NAT timer and the idle timeout produces mysterious stalls. A PING frame at an interval below the NAT timeout keeps bindings alive.

Deployment: Discovery, Servers, CPU Cost and Observability

How a browser finds out a server speaks HTTP/3

A browser cannot open a UDP connection to port 443 on faith, because many networks block it. HTTP/3 is therefore discovered, not assumed. The classic route is the Alt-Svc mechanism from RFC 7838: a response over HTTP/1.1 or HTTP/2 carries a header such as Alt-Svc: h3=":443"; ma=86400, telling the client that the same origin is reachable over HTTP/3 on UDP port 443 for the next 86,400 seconds. The newer route is the HTTPS DNS record (RFC 9460), which advertises alpn=h3 before any connection exists and allows a first-visit QUIC attempt.

Clients then race or fall back. Chrome and other browsers attempt QUIC and, if it fails, use TCP, sometimes starting both with a short stagger. This fallback is the safety net that makes deployment tolerable, and it is also why HTTP/3 adoption does not break sites behind UDP-blocking firewalls. Remember that it means a share of your users silently run HTTP/2, so you must keep the TCP path healthy.

Server and proxy support

Support is broad in 2026, though the details move and you should check current release notes before relying on a specific build.

  • nginx supports HTTP/3 through its QUIC module, enabled with the listen 443 quic reuseport; directive plus a normal ssl listener, and an add_header Alt-Svc line. Check your build was compiled against a TLS library with QUIC support.
  • Caddy enables HTTP/3 by default in recent releases, using the quic-go library.
  • Envoy supports HTTP/3 downstream and upstream, built on Google’s QUICHE library, configured via a QUIC transport socket and an HTTP/3 codec.
  • CDNs such as Cloudflare, Fastly and AWS CloudFront offer HTTP/3 as a toggle at the edge, usually with TCP-based HTTP/2 or HTTP/1.1 to your origin.

A minimal nginx sketch looks like this. Treat it as a pattern to adapt, since directive names and defaults change between versions.

server {
    listen 443 ssl;
    listen 443 quic reuseport;
    http2 on;
    ssl_certificate     /etc/ssl/example.pem;
    ssl_certificate_key /etc/ssl/example.key;
    ssl_protocols TLSv1.3;
    add_header Alt-Svc 'h3=":443"; ma=86400' always;
    location / { proxy_pass http://app_backend; }
}

Do not forget the firewall: UDP 443 must be open inbound, and security groups that were written for TCP-only HTTPS will quietly block QUIC. This is the single most common reason a “working” HTTP/3 deployment shows zero HTTP/3 traffic.

Trying it yourself: curl and aioquic

The quickest test is curl built with an HTTP/3 backend. Many distributions now ship one, and you can confirm with curl -V, which lists HTTP3 in its features if supported.

# Force HTTP/3 and print timing breakdown
curl --http3 -sS -o /dev/null -w \
  "version=%{http_version} connect=%{time_connect}s appconnect=%{time_appconnect}s ttfb=%{time_starttransfer}s\n" \
  https://example.com/

# Compare with HTTP/2 over TCP on the same URL
curl --http2 -sS -o /dev/null -w \
  "version=%{http_version} connect=%{time_connect}s appconnect=%{time_appconnect}s ttfb=%{time_starttransfer}s\n" \
  https://example.com/

For programmatic use, aioquic is a Python implementation of QUIC and HTTP/3 aimed at readability. The following sketch follows the structure of the project’s own http3_client.py example; the library’s API evolves, so check the current documentation before copying it into production.

import asyncio
from aioquic.asyncio.client import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration

class H3Client(QuicConnectionProtocol):
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        self.http = H3Connection(self._quic)
        self.done = asyncio.get_event_loop().create_future()
        self.body = b""

    def quic_event_received(self, event):
        for h3_event in self.http.handle_event(event):
            if isinstance(h3_event, HeadersReceived):
                print("headers:", h3_event.headers[:3])
            elif isinstance(h3_event, DataReceived):
                self.body += h3_event.data
                if h3_event.stream_ended:
                    self.done.set_result(len(self.body))

    async def get(self, host, path="/"):
        sid = self._quic.get_next_available_stream_id()
        self.http.send_headers(sid, [
            (b":method", b"GET"), (b":scheme", b"https"),
            (b":authority", host.encode()), (b":path", path.encode()),
        ], end_stream=True)
        self.transmit()
        return await self.done

async def main(host="example.com"):
    cfg = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
    async with connect(host, 443, configuration=cfg,
                       create_protocol=H3Client) as client:
        n = await client.get(host)
        print("received bytes:", n)

asyncio.run(main())

Running this against a server you control, with qlog output enabled (aioquic can write qlog traces via a quic_logger), is the fastest way to see packet numbers, frames and loss events in a form you can read.

The CPU cost of user-space transport

Here is a fact the marketing glosses over: QUIC is more expensive per byte than TCP on commodity servers. TCP benefits from decades of kernel and NIC offload: segmentation offload (TSO), receive coalescing (GRO), checksum offload and in some cases full TLS offload. QUIC’s per-packet encryption, user-space timers and per-datagram system calls add overhead, and early deployments reported CPU use substantially above TCP. I am not citing a single figure here because results vary widely with implementation, NIC and workload, and I would rather you measure your own.

Several mechanisms narrow the gap. UDP GSO (generic segmentation offload, UDP_SEGMENT in Linux) lets the stack hand the kernel one large buffer that is split into many datagrams late in the path, cutting per-packet system-call cost. UDP GRO does the reverse on receive. The sendmmsg and recvmmsg system calls batch many datagrams per call. Lately, io_uring and AF_XDP provide further reductions for those willing to adopt them. Some NICs now offer QUIC-aware crypto offload, though support is not universal.

The practical lesson is that enabling HTTP/3 at the edge shifts cost onto your CPU budget. Capacity-plan for it, and confirm your server build actually uses GSO.

Middleboxes, NAT and the UDP problem

UDP receives worse treatment on networks than TCP. Corporate firewalls may block UDP 443 outright. Some carrier-grade NATs expire UDP bindings in as little as 30 seconds. Certain DDoS mitigation appliances rate-limit UDP as a class. All of this is why fallback exists and why the real-world HTTP/3 success rate sits below 100% of attempts.

Path MTU is another trap. QUIC’s minimum supported maximum datagram size is 1200 bytes, chosen so that it works on nearly any IPv6-capable path. Implementations then use datagram packetization layer path MTU discovery (DPLPMTUD) to find larger sizes. Do not set a hard 1500-byte datagram assumption: tunnels, VPNs and PPPoE reduce the effective MTU, and fragmented UDP is routinely dropped.

Observability with qlog and friends

Because the wire is encrypted, observability has to move to the endpoints. The qlog format, a structured JSON logging schema defined by the IETF QUIC working group, records connection events in a standard shape: packets sent and received, frames, RTT updates, congestion window changes, loss detections. The qvis tool visualises qlog traces as sequence diagrams and congestion graphs.

Most production stacks can emit qlog, but enabling it for all connections is expensive. A common pattern is to sample, or to enable it conditionally for connections that exceed a latency threshold. Beyond qlog, export transport metrics: handshake success rate, share of connections falling back to TCP, 0-RTT acceptance rate, PTO counts per connection and the ratio of lost to sent packets. SSLKEYLOGFILE still works with Wireshark to decrypt captures in a lab, although never in production.

When HTTP/3 Does Not Help, and What Goes Wrong

The strongest claim a skeptical reader can make is “my users are on fast fibre, so who cares?” and it is largely right. HTTP/3 helps most where RTT is high, loss is non-trivial, networks change, or pages need many parallel streams. It helps least in the following cases.

  • Low-RTT, low-loss paths. Between a client and a nearby edge on a good wired link, a saved round trip is a few milliseconds. Measure real user monitoring before celebrating.
  • Large single-stream downloads. One bulk transfer has no head-of-line problem to solve. Throughput is then governed by congestion control and CPU, where TCP with offloads can win.
  • Server-to-server traffic inside a data centre. The paths are clean, the RTT is microseconds, and the CPU penalty of user-space QUIC is pure cost. Most operators keep internal hops on HTTP/2 or gRPC over TCP.
  • UDP-hostile networks. If a meaningful share of your users sits behind UDP blocks, you still pay for the fallback race.
  • Heavily TLS-offloaded estates. Hardware that accelerates TCP and TLS may have no QUIC equivalent.

There are also failure modes specific to operating QUIC. Large certificate chains collide with the amplification limit and add a round trip. Load balancers that ignore connection IDs break migration. Misconfigured Alt-Svc caching can send clients to an HTTP/3 endpoint that you have since disabled, and they keep trying until the ma lifetime expires or fallback kicks in. 0-RTT without replay protection turns harmless-looking retries into duplicate transactions. Each of these shows up in production, and none of them are visible in a synthetic benchmark on a single clean link.

On the security side, QUIC servers face UDP reflection and resource-exhaustion attacks that differ from TCP SYN floods. Retry tokens, stateless reset tokens and rate limiting are the mitigations. The IoT angle is worth a note: constrained devices often prefer CoAP or MQTT, and our IoT protocol latency benchmark comparison looks at when HTTP/3 is worth its weight on small devices and when it is not.

A short note on MASQUE

MASQUE is the IETF working group extending HTTP proxying to carry UDP and IP traffic. CONNECT-UDP (RFC 9298) lets a client ask an HTTP proxy to relay UDP datagrams, and these ride in HTTP datagrams (RFC 9297) over QUIC DATAGRAM frames. A client can thereby tunnel QUIC itself through an HTTP/3 proxy, which is the design behind several privacy relay services. The point for this article is that QUIC’s unreliable datagram extension is not a curiosity: it is the building block that makes HTTP/3 a general-purpose tunnelling substrate, and it matters if you plan VPN-like or media workloads.

Practical Recommendations

Treat HTTP/3 as an incremental, measurable change, not a migration. Turn it on at the edge first, since CDNs absorb the CPU and tuning burden, and keep HTTP/2 healthy beside it. Do not remove TCP support; fallback is part of the design.

Measure before and after using real user data. The metrics to watch are time to first byte and tail percentiles split by protocol version, segmented by network type. If your p50 barely moves but p95 on mobile improves, that is the expected shape. If neither moves, your audience probably sits on good networks and the work is optional.

Plan capacity for CPU. Verify that your server uses UDP GSO and that you are not CPU-bound in the packet path. Test load-balancer behaviour with a client that migrates, rather than assuming it works.

Handle 0-RTT deliberately. Allow it only for idempotent methods, and make origin behaviour explicit with the Early-Data header and 425 responses.

  • Open UDP 443 inbound and confirm with curl --http3.
  • Advertise via Alt-Svc and, where possible, an HTTPS DNS record.
  • Shrink certificate chains or enable certificate compression to stay under the 3x limit.
  • Confirm load balancers route by connection ID if you want migration.
  • Restrict 0-RTT to safe requests and monitor acceptance rate.
  • Export handshake, fallback, PTO and loss metrics, and sample qlog.
  • Keep TCP paths and monitoring in place.

Frequently Asked Questions

What is the QUIC protocol in simple terms?

QUIC is a transport protocol built on UDP that combines the connection setup, encryption and reliable delivery that TCP and TLS provide separately. It supports many independent streams on one connection, so a lost packet delays only the affected streams. It is standardised in RFC 9000, with TLS 1.3 integrated by RFC 9001, and carries HTTP/3 as its main application.

What is the difference between QUIC vs TCP?

TCP delivers one ordered byte stream, lives in the operating system kernel, and requires a separate TLS handshake on top. QUIC runs over UDP in user space, integrates TLS 1.3, offers multiple independent streams, encrypts most header fields, and identifies connections by connection IDs rather than address tuples. The result is fewer handshake round trips, no transport-level head-of-line blocking, and support for connection migration.

Is HTTP/3 always faster than HTTP/2?

No. HTTP/3 shows its biggest gains on high-latency, lossy or changing networks, mainly at tail percentiles. On clean, low-RTT links the difference is small, and user-space QUIC costs more CPU than offloaded TCP. Some large downloads can run slower. Always compare real user data split by protocol version before judging the benefit for your own audience.

Is 0-RTT in QUIC safe?

A 0-RTT connection is safe only for idempotent requests. Early data can be replayed by an attacker who captured it, and it lacks forward secrecy for the early flight. Servers should accept it only for safe methods such as GET, forward an Early-Data indication to origins, and answer unsafe replays with 425 Too Early, as RFC 8470 describes.

Which congestion control does QUIC use?

QUIC does not mandate one. RFC 9002 specifies a NewReno-style baseline, and implementations commonly offer Cubic and BBR as alternatives because the controller runs in user space. The choice affects fairness against TCP flows and behaviour under loss. Check your specific library or CDN documentation for its current default, since these defaults change between releases.

How do browsers know a server supports HTTP/3?

They learn through the Alt-Svc response header received over an earlier HTTP/1.1 or HTTP/2 connection, or from an HTTPS DNS record that advertises the h3 protocol before any connection. Browsers then try QUIC on UDP 443 and fall back to TCP if it fails, which is why a first visit is often still HTTP/2.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *