QUIC’s handshake is surprisingly two-phased, with the initial connection establishment happening before TLS 1.3 even begins its cryptographic exchange.

Let’s watch nghttp3 and ngtcp2 work together. Imagine we have a simple HTTP/3 server and client.

Server Setup (Conceptual):

# Assuming nghttp3 and ngtcp2 are built and installed
nghttp3_server --listen 127.0.0.1:443 --certificate cert.pem --private-key key.pem

Client Setup (Conceptual):

nghttp3_client --connect 127.0.0.1:443 --host example.com

When the client initiates, ngtcp2 on both sides steps in first. It’s responsible for the UDP transport and the QUIC protocol framing. This involves sending a Initial packet. This packet contains a Source Connection ID and a Destination Connection ID. The Destination Connection ID is generated by the client and sent to the server. The server, upon receiving this, will use it in its own outgoing packets. Crucially, this Initial packet also carries the first part of the TLS 1.3 handshake.

Once the server receives the Initial packet, it processes the QUIC-level information (like connection IDs and packet numbers) and then forwards the TLS ClientHello to the TLS library (nghttp3 uses OpenSSL or similar). The TLS library then generates its ServerHello and sends it back, again encapsulated within ngtcp2 packets. This back-and-forth continues until the TLS handshake is complete.

Here’s the mental model:

  1. QUIC Transport Layer (ngtcp2): This is the foundation. It handles UDP packetization, connection establishment (using connection IDs), flow control (at the transport layer), and reliable delivery of QUIC packets. It doesn’t care what’s inside the QUIC packets, only that they get to where they need to go reliably.
  2. HTTP/3 Framing Layer (nghttp3): This sits on top of QUIC. It understands HTTP/3 frames like SETTINGS, HEADERS, DATA, and PUSH_PROMISE. It translates these into QUIC streams. When nghttp3 wants to send a GET request, it tells ngtcp2 to send this data on a specific stream.
  3. TLS 1.3 Cryptographic Layer: This is multiplexed within the QUIC packets. The initial QUIC Initial packet contains the ClientHello. Subsequent QUIC packets carry the rest of the TLS handshake messages. Once TLS is established, the application data (HTTP/3 frames) is encrypted by TLS before being handed to ngtcp2 for QUIC packetization.

The real magic is how ngtcp2 manages multiple streams. When you make a request, nghttp3 asks ngtcp2 to open a new stream. ngtcp2 assigns a stream ID to this request. This stream ID is embedded within the QUIC packet. On the receiving end, ngtcp2 demultiplexes incoming packets based on the QUIC header’s Destination Connection ID and then, within those packets, uses the stream ID to deliver the payload to the correct nghttp3 stream handler.

Consider the ngtcp2_callbacks structure. This is where you hook ngtcp2’s internal events into your application logic. For instance, on_stream_open is called when a new stream is initiated, and on_stream_close when it’s terminated. You’d use these to manage your HTTP/3 request/response objects. The send_crypto_packet callback is critical; it’s how ngtcp2 hands you the TLS handshake data to be sent out.

A subtle but powerful aspect is how QUIC handles packet loss and congestion control independently of HTTP/3. If an HTTP/3 HEADERS frame is lost, ngtcp2’s loss detection and recovery mechanisms kick in, retransmitting the QUIC packet containing that frame. nghttp3 is largely unaware of this underlying transport-level retransmission; it just knows eventually its stream data arrives.

The next hurdle is understanding how ngtcp2’s version negotiation works when clients and servers might not agree on the QUIC version.

Want structured learning?

Take the full Quic course →