QUIC servers, by default, are surprisingly bad at actually using the network efficiently, often performing worse than TCP.

Let’s spin up a QUIC server using aioquic, a popular Python implementation, and see how it all fits together. We’ll need a certificate and private key first. For local testing, openssl is our friend:

openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout private.key -out certificate.pem \
  -subj "/CN=localhost"

Now, let’s write a minimal aioquic server. This will listen on UDP port 4433 and serve files from the current directory.

import logging
import os
import ssl
import time

from aioquic.asyncio.server import Server
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.logger import QuicFileLogger

# Configure logging
logging.basicConfig(level=logging.INFO)

# Certificate and key paths
CERTIFICATE = "certificate.pem"
PRIVATE_KEY = "private.key"

# Server configuration
class HttpHandler:
    def __init__(self):
        self.websockets = set()
        self.http_messages = {}

    async def handle_request(self, stream_id, headers, data):
        # Very basic HTTP/3 request handling for serving files
        if headers[0][1] == b"/":
            filename = "index.html"
        else:
            filename = headers[0][1].decode().lstrip("/")

        filepath = os.path.join(".", filename)
        if os.path.exists(filepath):
            with open(filepath, "rb") as f:
                content = f.read()
            response_headers = [
                (b":status", b"200"),
                (b"content-length", str(len(content)).encode()),
                (b"content-type", b"text/html"), # Simplified, should infer
            ]
            await self.send_headers(stream_id, response_headers)
            await self.send_data(stream_id, content)
        else:
            response_headers = [
                (b":status", b"404"),
                (b"content-length", b"0"),
            ]
            await self.send_headers(stream_id, response_headers)

    async def send_headers(self, stream_id, headers):
        # Placeholder for actual sending logic in a real server
        print(f"Sending headers for stream {stream_id}: {headers}")

    async def send_data(self, stream_id, data):
        # Placeholder for actual sending logic in a real server
        print(f"Sending data for stream {stream_id}: {len(data)} bytes")

async def run_server():
    # QUIC configuration
    configuration = QuicConfiguration(
        is_client=False,
        # For production, use a proper certificate and private key
        certificate=ssl.get_default_verify_paths().cafile, # Placeholder, use your cert
        private_key="private.key",
        # quic_logger=QuicFileLogger("./logs"), # Uncomment for logging
    )

    # Create HTTP handler
    http_handler = HttpHandler()

    # Create QUIC server
    server = Server(
        configuration=configuration,
        # Pass the HTTP handler to process requests
        protocol_handler=http_handler,
    )

    # Start listening on UDP port 4433
    await server.serve(host="0.0.0.0", port=4433)
    logging.info("QUIC server started on UDP port 4433")

if __name__ == "__main__":
    # Create a dummy index.html for testing
    if not os.path.exists("index.html"):
        with open("index.html", "w") as f:
            f.write("<h1>Hello from QUIC!</h1>")

    import asyncio
    asyncio.run(run_server())

To run this, save it as quic_server.py, ensure aioquic is installed (pip install aioquic), and run python quic_server.py. You’ll need a QUIC-capable client to connect. For Chrome, you can enable "Experimental QUIC protocol" in chrome://flags/ and then navigate to https://localhost:4433.

The core of the QUIC server is aioquic.quic.configuration.QuicConfiguration. This object holds the TLS certificate and private key, essential for establishing a secure connection. is_client=False tells it we’re a server. The protocol_handler is where your application logic lives – in this basic example, it’s a simplified HttpHandler that tries to serve index.html.

The Server class from aioquic.asyncio.server orchestrates the QUIC connection lifecycle. It binds to a UDP port and, upon receiving QUIC packets, hands them off to the QUIC protocol implementation. It then uses the protocol_handler to respond to application-level events, like incoming HTTP requests.

Tuning QUIC is a multi-faceted affair, and most of it happens outside your application code, at the operating system and QUIC stack level. The aioquic configuration has a few parameters, but the real power lies in OS-level tuning and client/server alignment.

One critical, yet often overlooked, aspect of QUIC performance is its congestion control. Unlike TCP’s well-understood Cubic or BBR, QUIC’s congestion control is pluggable. aioquic defaults to a basic implementation, but for high-performance scenarios, you’d want to integrate a more advanced algorithm. Libraries like quic-interop-runner can help test different congestion control algorithms against each other.

The default congestion control in many QUIC implementations, including aioquic’s basic one, is often a simplified Pacing-based algorithm. This means it tries to send data at a steady rate, which is good for reducing jitter, but it doesn’t react as aggressively to network congestion as algorithms like BBR. For interactive applications or those with highly variable bandwidth, a more sophisticated algorithm like BBR (if available and supported by the kernel) or a custom Pacing-based controller with better loss recovery mechanisms would be significantly better.

The QuicConfiguration object itself has parameters like idle_timeout (how long to keep a connection alive without activity) and max_datagram_frame_size (the maximum size of UDP datagrams to send, impacting fragmentation). Setting max_datagram_frame_size to 1452 (the common MTU for Ethernet minus UDP and IP headers) is generally a good starting point to avoid IP fragmentation.

When deploying, you’ll want to configure your firewall to allow UDP traffic on your chosen port (e.g., 4433). For TLS, aioquic uses the standard Python ssl module. For production, you absolutely must use a valid certificate issued by a Certificate Authority, not self-signed ones or the placeholder ssl.get_default_verify_paths().cafile used here.

The most surprising thing about QUIC’s performance tuning is that the biggest gains often come from optimizing the loss detection and recovery mechanisms, not just the pacing. Standard TCP loss detection (like Fast Retransmit) can be slow to react in a high-latency, high-loss environment that QUIC often targets. Advanced QUIC implementations might use techniques like Early Data (0-RTT) retransmission or more aggressive probing for available bandwidth after packet loss.

The next step is understanding how QUIC’s stream multiplexing differs from TCP’s single-stream model and the implications for head-of-line blocking.

Want structured learning?

Take the full Quic course →