QUIC clients in Python are surprisingly easy to build, but the journey often starts with the misconception that you’re just swapping TCP for UDP.

Let’s see a basic QUIC client in action. This script connects to a server, sends a simple HTTP/3 request, and prints the response.

import asyncio
import ssl
from urllib.parse import urlparse

from aioquic.asyncio.client import connect
from aioquic.quic.configuration import QuicConfiguration

async def run_client(host, port, path):
    configuration = QuicConfiguration(
        is_client=True,
        ssl_context=ssl.create_default_context(ssl.Purpose.SERVER_AUTH),
    )

    async with connect(host, port, configuration=configuration) as client:
        # Send an HTTP/3 request
        request = f"GET {path} HTTP/3\r\nHost: {host}\r\nConnection: close\r\n\r\n"
        await client.send_stream_data(0, request.encode(), end_stream=True)

        # Receive the response
        response_data = b""
        async for chunk, _ in client.stream_data_quic_received(0):
            response_data += chunk

        print(response_data.decode())

if __name__ == "__main__":
    host = "example.com"  # Replace with your QUIC server host
    port = 443            # Replace with your QUIC server port
    path = "/"            # Replace with your desired path

    asyncio.run(run_client(host, port, path))

This example uses aioquic, a Python library for QUIC. The QuicConfiguration object is crucial. It tells aioquic we’re acting as a client and sets up the necessary TLS context for QUIC’s encrypted transport. The connect function establishes the QUIC connection. Notice we’re using stream_data_quic_received on a specific stream ID (0 in this case, common for HTTP/3 control streams and initial requests).

The core problem QUIC solves is the Head-of-Line (HOL) blocking inherent in TCP. In TCP, if a packet is lost, all subsequent packets on that connection must wait for retransmission, even if they are for different logical streams. QUIC, however, multiplexes streams independently over a single UDP connection. Packet loss on one stream doesn’t affect others. This is achieved through packet numbering and explicit stream management within the QUIC protocol itself, rather than relying on the operating system’s TCP stack.

The aioquic library abstracts away much of the low-level QUIC packet handling. You interact with it through streams. A stream is a bidirectional sequence of bytes, similar to a TCP socket. When you call client.send_stream_data(stream_id, data, end_stream=True), aioquic takes care of packaging that data into QUIC packets, encrypting it, and sending it over UDP. The end_stream=True flag signals the end of data for that particular stream.

The QuicConfiguration object is where you tweak many aspects of the connection. Beyond is_client and ssl_context, you can specify secrets_log_file for debugging TLS, quic_version to select a specific QUIC protocol version, and idle_timeout to set how long the connection can remain idle before being closed. For more advanced scenarios, you might also configure original_connection_id or max_datagram_frame_size.

When you connect to a server, aioquic performs the QUIC handshake, which includes a TLS 1.3 handshake. This handshake establishes encryption keys and negotiates protocol parameters. The ssl_context you provide is used here. For client authentication, you would add certfile and keyfile to the ssl.SSLContext.

The client.stream_data_quic_received(stream_id) method is an asynchronous iterator. It yields chunks of data received on the specified stream, along with a flag indicating if the stream has ended. This allows for efficient processing of responses, especially for large amounts of data.

One detail often overlooked is the role of the Connection ID. QUIC uses Connection IDs to uniquely identify a connection between endpoints, independent of the underlying IP address and port. This allows a client to switch networks (e.g., from Wi-Fi to cellular) and maintain an active QUIC connection without interruption, a feature called "connection migration." In the client configuration, you can influence the initial Connection ID used, though typically the server dictates the Server Connection ID.

If you want to implement features like UDP data loss detection or congestion control, you’d be diving into the aioquic.quic.protocol.QuicConnection class directly. This is where you’d manage packet timers, retransmissions, and congestion window updates, but for most application-level QUIC usage, aioquic.asyncio.client and aioquic.asyncio.server provide a sufficient abstraction.

Once you’ve got this basic client working, the next logical step is to explore how to manage multiple streams concurrently within a single QUIC connection, perhaps for fetching multiple resources in parallel.

Want structured learning?

Take the full Quic course →