QUIC’s handshake is actually an HTTP/3 handshake layered on top of TLS 1.3, which is an astonishing amount of complexity to get a UDP connection established.
Let’s see it in action. We’ll use the msquic library to spin up a simple echo server and client.
First, the server. You’ll need to include msquic.h and link against msquic.lib.
#include <msquic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Global MsQuic API handle
HQUIC MsQuicApi;
// Callback for connection events
void
MsQuicCallback(
_In_ HQUIC Connection,
_In_ void* Context,
_In_ QUIC_EVENT* Event
) {
UNREFERENCED_PARAMETER(Context);
switch (Event->Type) {
case QUIC_EVENT_LISTENER_NEW_CONNECTION:
printf("New connection established.\n");
break;
case QUIC_EVENT_CONNECTION_SHUTDOWN_INITIATED:
printf("Connection shutdown initiated.\n");
break;
case QUIC_EVENT_CONNECTION_CLOSED:
printf("Connection closed.\n");
break;
case QUIC_EVENT_LISTENER_STOPPED:
printf("Listener stopped.\n");
break;
default:
break;
}
}
// Callback for stream events
void
StreamCallback(
_In_ HQUIC Stream,
_In_ void* Context,
_In_ QUIC_EVENT* Event
) {
UNREFERENCED_PARAMETER(Context);
switch (Event->Type) {
case QUIC_EVENT_STREAM_RECEIVED_DATA: {
QUIC_STREAM_EVENT_RECEIVED_DATA* Data = &Event->StreamReceivedData;
printf("Received %zu bytes on stream.\n", Data->Buffer->Length);
// Echo data back
MsQuic->StreamSend(Stream, Data->Buffer);
break;
}
case QUIC_EVENT_STREAM_SHUTDOWN_COMPLETE:
printf("Stream shutdown complete.\n");
MsQuic->StreamClose(Stream);
break;
default:
break;
}
}
int main() {
QUIC_STATUS status;
HQUIC listener;
QUIC_LISTENER_CONFIG listenerConfig = {0};
QUIC_REGISTRATION_CONFIG registrationConfig = {"msquic-echo-server"};
// Initialize MsQuic
status = MsQuicOpenVersion(MSQUIC_FULL_VERSION, &MsQuicApi);
if (QUIC_FAILED(status)) {
fprintf(stderr, "MsQuicOpenVersion failed: 0x%X\n", status);
return 1;
}
// Create a registration
HQUIC registration;
status = MsQuicApi->RegistrationOpen(®istrationConfig, ®istration);
if (QUIC_FAILED(status)) {
fprintf(stderr, "RegistrationOpen failed: 0x%X\n", status);
MsQuicApi->Close(MsQuicApi);
return 1;
}
// Configure the listener
listenerConfig.Flags = QUIC_LISTENER_FLAG_USE_TLS;
listenerConfig.Security.CertificateHash = NULL; // Use default certificate
listenerConfig.Security.PrivateKey = NULL;
listenerConfig.Callback = MsQuicCallback;
listenerConfig.Context = NULL;
// Start the listener on UDP port 4433
status = MsQuicApi->ListenerOpen(registration, &listenerConfig, &listener);
if (QUIC_FAILED(status)) {
fprintf(stderr, "ListenerOpen failed: 0x%X\n", status);
MsQuicApi->RegistrationClose(registration);
MsQuicApi->Close(MsQuicApi);
return 1;
}
QUIC_ADDR listenAddress = {0};
QuicAddrSetFamily(&listenAddress, AF_INET);
QuicAddrSetPort(&listenAddress, 4433);
status = MsQuicApi->ListenerStart(listener, 1, &listenAddress);
if (QUIC_FAILED(status)) {
fprintf(stderr, "ListenerStart failed: 0x%X\n", status);
MsQuicApi->ListenerClose(listener);
MsQuicApi->RegistrationClose(registration);
MsQuicApi->Close(MsQuicApi);
return 1;
}
printf("Server started on UDP port 4433.\n");
// Keep the server running
getchar();
// Clean up
MsQuicApi->ListenerStop(listener);
MsQuicApi->ListenerClose(listener);
MsQuicApi->RegistrationClose(registration);
MsQuicApi->Close(MsQuicApi);
return 0;
}
The core of the server is the MsQuicCallback and StreamCallback. MsQuicCallback handles connection-level events like new connections and shutdowns. StreamCallback deals with data on individual streams, which is where our echo logic lives. When data arrives (QUIC_EVENT_STREAM_RECEIVED_DATA), we immediately send it back using StreamSend.
Now, for the client. It’s similar, but initiates the connection.
#include <msquic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Global MsQuic API handle
HQUIC MsQuicApi;
// Callback for connection events
void
MsQuicCallback(
_In_ HQUIC Connection,
_In_ void* Context,
_In_ QUIC_EVENT* Event
) {
UNREFERENCED_PARAMETER(Context);
switch (Event->Type) {
case QUIC_EVENT_CONNECTION_ESTABLISHED:
printf("Connection established.\n");
// Signal that connection is ready to send data
*((BOOLEAN*)Context) = TRUE;
break;
case QUIC_EVENT_CONNECTION_CLOSED:
printf("Connection closed.\n");
break;
default:
break;
}
}
// Callback for stream events
void
StreamCallback(
_In_ HQUIC Stream,
_In_ void* Context,
_In_ QUIC_EVENT* Event
) {
UNREFERENCED_PARAMETER(Context);
switch (Event->Type) {
case QUIC_EVENT_STREAM_RECEIVED_DATA: {
QUIC_STREAM_EVENT_RECEIVED_DATA* Data = &Event->StreamReceivedData;
printf("Received %zu bytes on stream: %.*s\n",
Data->Buffer->Length,
(int)Data->Buffer->Length,
(char*)Data->Buffer->Data);
break;
}
case QUIC_EVENT_STREAM_SHUTDOWN_COMPLETE:
printf("Stream shutdown complete.\n");
MsQuic->StreamClose(Stream);
break;
default:
break;
}
}
int main(int argc, char** argv) {
if (argc != 2) {
printf("Usage: %s <server_address>\n", argv[0]);
return 1;
}
QUIC_STATUS status;
HQUIC connection;
HQUIC stream;
QUIC_CONNECTION_CONFIG connectionConfig = {0};
QUIC_REGISTRATION_CONFIG registrationConfig = {"msquic-echo-client"};
BOOLEAN connectionReady = FALSE;
// Initialize MsQuic
status = MsQuicOpenVersion(MSQUIC_FULL_VERSION, &MsQuicApi);
if (QUIC_FAILED(status)) {
fprintf(stderr, "MsQuicOpenVersion failed: 0x%X\n", status);
return 1;
}
// Create a registration
HQUIC registration;
status = MsQuicApi->RegistrationOpen(®istrationConfig, ®istration);
if (QUIC_FAILED(status)) {
fprintf(stderr, "RegistrationOpen failed: 0x%X\n", status);
MsQuicApi->Close(MsQuicApi);
return 1;
}
// Configure the connection
connectionConfig.Callback = MsQuicCallback;
connectionConfig.Context = &connectionReady; // Pass flag to callback
connectionConfig.ClientDefaults.InitialBandwidth = 10000000; // 10 Mbps
// Open a connection
status = MsQuicApi->ConnectionOpen(registration, argv[1], 4433, &connectionConfig, &connection);
if (QUIC_FAILED(status)) {
fprintf(stderr, "ConnectionOpen failed: 0x%X\n", status);
MsQuicApi->RegistrationClose(registration);
MsQuicApi->Close(MsQuicApi);
return 1;
}
// Wait for connection to be established
while (!connectionReady) {
MsQuicApi->ProcessEvents(MsQuicApi);
Sleep(10); // Small delay to avoid busy-waiting
}
// Create a stream
status = MsQuicApi->StreamOpen(connection, QUIC_STREAM_OPEN_FLAG_NONE, StreamCallback, NULL, &stream);
if (QUIC_FAILED(status)) {
fprintf(stderr, "StreamOpen failed: 0x%X\n", status);
MsQuicApi->ConnectionClose(connection, QUIC_CONNECTION_ERROR_NO_ERROR, 0);
MsQuicApi->RegistrationClose(registration);
MsQuicApi->Close(MsQuicApi);
return 1;
}
// Send data
const char* message = "Hello, QUIC!";
QUIC_BUFFER buffer = { (UINT32)strlen(message), (void*)message };
status = MsQuic->StreamSend(stream, &buffer);
if (QUIC_FAILED(status)) {
fprintf(stderr, "StreamSend failed: 0x%X\n", status);
} else {
printf("Sent: %s\n", message);
}
// Wait for some time to receive the echo
Sleep(2000);
// Clean up
MsQuicApi->StreamShutdown(stream, QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0);
MsQuicApi->ConnectionClose(connection, QUIC_CONNECTION_ERROR_NO_ERROR, 0);
MsQuicApi->RegistrationClose(registration);
MsQuicApi->Close(MsQuicApi);
return 0;
}
The client first establishes a connection to the server. The MsQuicCallback is crucial here to know when QUIC_EVENT_CONNECTION_ESTABLISHED fires, indicating the handshake is complete. Then, a stream is opened, and data is sent using StreamSend. The StreamCallback on the client side will receive the echoed data.
The entire process is managed by MsQuicApi and its various Open, Start, Send, Close functions. You register callbacks for connection and stream events, and the library notifies you as things happen. The QUIC_BUFFER structure is fundamental for passing data in and out.
One of the most surprising aspects of QUIC, and thus msquic, is its inherent multiplexing. Unlike TCP where a single connection is a single byte stream, a QUIC connection can have multiple independent streams. If one stream experiences packet loss, it doesn’t block the others. This is a critical difference for performance, especially on lossy networks, and it’s managed entirely by the QUIC protocol itself, with msquic providing the API to create and interact with these streams.
The next step is typically exploring stream control, like setting flow control limits or using stream priorities.