In a previous article, we delved into the TCP/IP networking model. As the article concluded, we examined the significant advancements introduced by HTTP/2, particularly its ability to multiplex multiple requests over a single TCP connection. This approach not only enhanced efficiency but also addressed many of the limitations found in earlier protocols.
However, HTTP/2 over TCP faced two significant challenges:
- Head-of-Line Blocking: If a packet is lost in one stream, it can block the delivery of packets in all other streams sharing the same connection.
- Connection Persistence: Since TCP tracks connections using packet sequence numbers tied to specific IP addresses, switching networks (e.g., from Wi-Fi to mobile data) forces the connection to drop, requiring a complete re-establishment.
To overcome these limitations, a team at Google began developing a new protocol in 2012 called QUIC. The goal was to shift key features, including multiplexing, connection migration, and encryption, to the transport layer. By doing so, QUIC addresses many of the shortcomings of the older model, as we’ll explore in this article.
QUIC (Quick UDP Internet Connections) operates over UDP instead of TCP, offering a faster and more lightweight alternative. However, UDP lacks built-in reliability, functioning on a "fire-and-forget" basis. It simply sends packets without ensuring they arrive or tracking whether they are lost.
To overcome this limitation, QUIC incorporates its own sophisticated connection tracking and management mechanisms, providing the reliability and efficiency needed for modern internet applications.
The QUIC specifications begin by assigning a unique Connection ID (CID) to each connection. Instead of relying on source and destination IP addresses for connection tracking, the CID serves as the identifier, shared between the source and destination. Even if IP addresses change, the server uses the known CID to maintain the same connection for communication.
Consider a visitor session on a website. If the website tracks visitors by their IP address, a change in the IP address makes it appear as a different visitor. However, if both the visitor and the website use a unique session ID, the website can still recognize the visitor even if they switch networks, as long as the same session ID is shared. That’s precisely the change QUIC introduces compared to TCP.
Like TCP, QUIC keeps track of which packets have been received and retransmits any that are lost. However, it operates at the stream level rather than the connection level.
By shifting multiplexing from the application layer to the transport layer, QUIC can monitor each stream independently. If a packet belonging to one stream is dropped, it doesn't block the entire connection. Instead, packets from other streams continue to flow uninterrupted while the transport layer focuses on retransmitting the lost packet.
This approach eliminates the head-of-line blocking problem of the TCP protocol, improving efficiency and reduces latency, especially in environments with high packet loss.
TCP lacks built-in encryption capabilities; it simply transmits packets received from the application layer. As a result, a separate handshake is required to negotiate encryption after the TCP connection is established.
QUIC, on the other hand, handles encryption internally. This means a single handshake both initiates the connection and sets up encryption, reducing the latency involved in establishing connections.
Before we look into the QUIC handshake, let's take a look at the TLS/TCP handshake:
// TCP Handshake
$CLIENT: SYN, SEQ=1000
$SERVER: SYN-ACK, SEQ=2000, ACK=1001
$CLIENT: ACK, SEQ=1001, ACK=2001
// TLS Handshake
$CLIENT: HELLO, VERSIONS=1.2,1.3, CIPHER_SUITES=X,Y,Z, RAND=XXXX
$SERVER: HELLO, VERSION=1.3, CIPHER_SUITE=X, RAND=XXXX, CERTIFICATE=XXX
$CLIENT: CERTIFICATE=VALID, SECRET=YYY
$SERVER: FINISHED
$CLIENT: FINISHED
If you're not familiar with the interactions of the TCP/TLS handshake, check my previous article.
Now, let's investigate the QUIC handshake:
$CLIENT: HELLO, SCID=1000, CIPHER_SUITES=X,Y,Z, RAND=XXXX, KEY=XXX
$SERVER: HELLO, SCID=2000, DCID=1000, CIPHER_SUITE=X, RAND=XXXX, CERTIFICATE=XXX
The client starts by sending:
- A source connection ID (SCID).
- Supported cipher suites (encryption algorithms).
- A client random (a 32-byte random number).
- A public key.
The server receives these parameter and sends:
- A source connection ID (SCID).
- A destination connection ID (DCID) matching the client's SCID.
- The selected cipher suite.
- A server random.
- The server certificate (including the public key).
Each party identifies itself using the SCID and the other party using the DCID. In the example above, the client identifies itself with an ID of 1000, while the server identifies itself with an ID of 2000 and includes the client's ID as the DCID. After the handshake, when the client sends data to the server, it uses the server's SCID as the DCID. By using the SCID and DCID, both parties can track transmissions independently of the IP addresses.
In the initial packet, the client shares its supported cipher suites, a random number, and its public key. The server responds with the selected cipher suite, its own random number, and its certificate.
Using these parameters, both the client and the server independently generate a shared secret. This secret is never transmitted between them; instead, it is used internally to derive a set of symmetric keys for encryption.
Before the client can send any data, it verifies the server's certificate through a trusted certificate authority and only proceeds with transmission if the certificate is valid.
Once the initial handshake is complete and both parties have declared their SCIDs and are aware of each other's DCID, packets can begin flowing through the connection.
Each packet in quick has a header part and data part. The data part contains multiple frames, with some frames carrying the actual application data, while others provide important connection parameters and status information.
SCIDs and DCIDs are included in the header of each packet, along with a packet number. This packet number is used to track lost or out-of-order packets, much like the sequence and acknowledgment numbers in TCP. However, unlike the SEQ/ACK numbers in TCP, the packet numbers in QUIC increase incrementally:
Direction | Packet numbers
--------------------------------------
Client → Server | 0, 1, 2, 3, 4, 5...
Server → Client | 0, 1, 2, 3, 4, 5...
Each packet that has application data includes at last one STREAM
frame. This frame identifies the stream ID the data belongs to. Multiple STREAM
frames can be included in the same packet and include data from different streams:
|-------------------------+------------------------------|
| Packet Header | SCID, DCID, PACKET_NUMBER |
|-------------------------+------------------------------|
|-------------------------+------------------------------|
| STREAM Frame (Stream 1) | Data for Stream 1 |
|---------------------+----------------------------------|
| STREAM Frame (Stream 2) | Data for Stream 2 |
|---------------------+----------------------------------|
| STREAM Frame (Stream n) | Data for Stream n |
|---------------------+----------------------------------|
The example packet above contains multiple frames from different streams. This is what enables multiplexing in QUIC, as multiple streams can share data in parallel within the same packet.
If a packet is lost, the receiver can request the sender to retransmit it. Internally, the transport layer tracks lost packets and the streams they belong to. This allows other packets, including those with data for unaffected streams, to continue flowing while the retransmission of the lost packet is requested.
Imagine the results of three requests being sent over a single connection. If a packet containing data for request 1 is lost, packets containing data for requests 2 and 3 will continue to flow unaffected. This means the application can receive and process the results for requests 2 and 3 without delay, while the lost packet for request 1 is being retransmitted. This ability to continue processing data from other streams ensures minimal disruption to the overall communication, improving efficiency and reducing latency.
HTTP/2 introduced the concept of streams and frames, allowing multiple requests and responses to be multiplexed over a single TCP connection. Since QUIC fundamentally changes how multiplexing and connection migration works, the existing HTTP/2 protocol was no longer compatible. A new version of HTTP was needed, one designed specifically to operate over QUIC. This led to the creation of HTTP/3, which retains the stream-based architecture of HTTP/2 but delegated multiplexing to the QUIC protocol.
HTTP/3 introduces its own concept of streams and frames, separate from QUIC's streams and frames. In essence, each HTTP stream is mapped to a corresponding QUIC stream, and HTTP frames are encapsulated within QUIC frames for transport.
There are three types of HTTP streams:
- Request streams.
- Control streams.
- Push streams.
A request stream is initiated by a client and maps to a bidirectional QUIC stream. Meaning, both data sent by the client and the server are mapped to the same stream. Hence, bidirectional.
A control stream is mapped to a unidirectional QUIC stream. Both server and client initiate their own control streams to share their settings for the connection.
A push stream is an optional, unidirectional QUIC stream initiated by the server to send responses for certain resources before the client explicitly requests them. For example, the server may proactively send asset files such as CSS and JavaScript after delivering the initial HTML response. This approach improves load times by reducing the latency associated with separate resource requests.
Since all streams are mapped to QUIC streams, HTTP relies on QUIC to handle stream management and multiplexing.
Data transferred through streams are broken down into frames. Each frame includes its type, data size, and payload.
The most common frame types are:
DATA
HEADERS
SETTINGS
GOAWAY
DATA
and HEADERS
frames are used in both request and push streams to carry the headers and payload of requests and responses. Each request consists of a single HEADERS
frame followed by one or more DATA
frames. Similarly, each response includes a single HEADERS
frame and one or more DATA
frames.
The SETTINGS
frame is sent over a control stream. It carries configuration parameters that influence how the two parties (client & server) communicate, including preferences and limits on peer behavior.
The GOAWAY
frame is sent over a control stream and is used to initiate a graceful termination of the connection. When the client receives a GOAWAY
frame from the server, it stops sending new requests but continues to receive ongoing responses.
With a shorter handshake, support for connection migration, and built-in flow control at the transport layer, QUIC offers significant improvements to how clients and servers communicate over the internet. I wasn't surprised to find that ~34% of websites now run HTTP/3 with QUIC, including major platforms like YouTube, LinkedIn, Amazon, WordPress, and Instagram. Additionally, major browsers such as Chrome, Safari, and Firefox support HTTP/3 over QUIC, further driving its widespread adoption.
As for CDNs and proxies, HTTP/3 is supported by many of the most popular ones:
- Cloudflare.
- CloudFront.
- Nginx.
- Caddy (enabled by default).
- Traefik.