1. Blog
  2. Http3 Quic Server
Ranti

Rantideb Howlader

@ranti

Connect
Search PostsReading ListTimelineBlog Stats

On this page

The Problem with TCP
Under the Hood of QUIC
Preparing Your Environment
Building the Server
Verification and Advanced Tuning

Build Your Own HTTP/3 Server

Rantideb Howlader•February 10, 2026 (1mo ago)•15 min read•
By Rantideb Howlader

Welcome to the cutting edge of the web.

If you have already followed the guide on building an HTTP/1.1 server, you know how to talk to the kernel using sockets. You know how to handle TCP connections. But today, we are going to break everything you learned and start over.

Modern web performance is no longer about just sending bytes faster. It is about a fundamental problem in how the internet was designed forty years ago. We are going to learn why TCP is holding us back and how a new protocol called QUIC is fixing it.

By the end of this guide, you will have a working HTTP/3 server written in Python.

The Problem with TCP

Before we write a single line of code, we need to understand why we are doing this. If TCP worked fine for decades, why are we changing it now?

1. Head-Of-Line Blocking

In our previous server, we used TCP. TCP is a reliable stream. If you send three images over a single TCP connection, they must arrive in order.

Imagine three trucks driving down a one-lane road:

  1. Truck A (Small CSS file)
  2. Truck B (Giant Image)
  3. Truck C (JavaScript file)

If Truck B gets a flat tire, Truck C cannot pass it. Even though Truck C is perfectly fine, it must sit and wait until Truck B is fixed. In networking terms, if one packet of the giant image is lost, your whole website stops loading while the computer waits for that one tiny piece to be resent.

This is called Head-of-Line Blocking (HOL Blocking). It is the single biggest performance killer on the mobile web.

2. The OSI Layer Trap

In our HTTP/1.1 server, we did not worry about security. In the real world, you use TLS (HTTPS).

TCP and TLS live in different layers.

  1. The computer does a TCP handshake (Takes time).
  2. Then it does a TLS handshake (Takes more time).

This means before a single byte of your webpage is sent, the browser and server have already chatted back and forth several times just to say hello securely. On a slow 4G connection, this adds seconds of delay.

3. The UDP Solution

To fix this, Google (and later the IETF) decided to move away from TCP. Instead, we use UDP.

UDP is lean. It does not care about order. It does not care about reliability. It just throws packets at the destination and hopes for the best.

Wait, how can a website work if the packets are unreliable?

The answer is QUIC. We take the speed of UDP and we build the reliability and security inside the protocol itself. Instead of having a TCP layer and a TLS layer, we mash them together into one fast layer.

Under the Hood of QUIC

QUIC is the heart of HTTP/3. You cannot understand the server without understanding the protocol.

4. Direct TLS Integration

In HTTP/3, the handshake is combined. Since we are using UDP, we do not need to wait for a connection to be established before starting the security talk.

In many cases, if you have visited the site before, you can use 0-RTT (Zero Round Trip Time). This means the browser sends the request inside the first packet. The server receives the packet, decodes the request, and sends the data back immediately.

There is no waiting. It is as close to instant as physics allows.

5. Connection IDs vs IP Addresses

Have you ever been on a Zoom call on your phone, walked out of your house, and had the call drop as you switched from Wi-Fi to 5G?

This happens because TCP identifies a connection by your IP address. When your IP changes from Wi-Fi to 5G, the old TCP connection is dead.

QUIC uses a Connection ID. Think of it like a name tag. Even if your address changes, your name tag stays the same. The server sees the same name tag and keeps the data flowing. You do not even notice the switch. This is called Connection Migration.

6. Independent Streams

Remember the trucks on the one-lane road?

QUIC turns that one-lane road into a multi-lane highway. Each file (CSS, JS, Image) gets its own Stream. If the image stream loses a packet, the CSS and JS streams keep moving in their own lanes.

One failure no longer breaks the whole page.

Frames and Packets

In TCP, we just sent a pile of bytes. In QUIC, we send Packets, and inside those packets are Frames.

Common Frames:

  • STREAM Frame: Contains your actual webpage data.
  • ACK Frame: Tells the other side 'I got your packet'.
  • CRYPTO Frame: Handles the security handshake.
  • PADDING Frame: Makes packets a certain size to prevent hackers from guessing what is inside.

QPACK Header Compression

In HTTP/2, we used HPACK to compress headers. But HPACK required strictly ordered streams. Since QUIC is unordered, we needed a new way.

QPACK allows us to compress headers even when packets arrive out of order. It uses a dynamic table to remember common headers (like User-Agent) so we do not have to send them repeatedly.

Preparing Your Environment

We cannot build a QUIC server with just a simple Python script using standard libraries. The standard library is too old for this.

7. Why We Use aioquic

To build this, we will use a library called aioquic.

QUIC is incredibly complex to write from scratch. It handles encryption, congestion control, and stream management. Coding this yourself would take months. aioquic is a high-performance implementation that lets us focus on the HTTP/3 logic.

Installing Dependencies

First, make sure you have Python 3.9 or higher.

Create a new folder and a virtual environment:

mkdir http3-server
cd http3-server
python3 -m venv venv
source venv/bin/activate

Now install the library:

pip install aioquic cryptography

The Role of Cryptography

QUIC mandates TLS 1.3. You cannot have unencrypted HTTP/3.

The cryptography library will help us generate certificates. In production, you would use Let's Encrypt, but for our 'Step 1' we will make our own.

8. Generating Self-Signed Certificates

We need a certificate and a private key. This is the 'ID Card' our server shows the browser.

Certification Authority (CA) Theory

Browsers only trust certificates signed by someone they know (like Digicert or Google). Since we are making our own, your browser will show a warning. That is fine for development.

Generating the Files

Save this script as generate_cert.py and run it:

from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import datetime
 
# Generate a private key
key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,
)
 
# Details about our server
subject = issuer = x509.Name([
    x509.NameAttribute(NameOID.COUNTRY_NAME, u'US'),
    x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u'California'),
    x509.NameAttribute(NameOID.LOCALITY_NAME, u'San Francisco'),
    x509.NameAttribute(NameOID.ORGANIZATION_NAME, u'My HTTP3 Server'),
    x509.NameAttribute(NameOID.COMMON_NAME, u'localhost'),
])
 
# Create the certificate
cert = x509.CertificateBuilder().subject_name(
    subject
).issuer_name(
    issuer
).public_key(
    key.public_key()
).serial_number(
    x509.random_serial_number()
).not_valid_before(
    datetime.datetime.utcnow()
).not_valid_after(
    # Our certificate lasts for one year
    datetime.datetime.utcnow() + datetime.timedelta(days=365)
).add_extension(
    x509.SubjectAlternativeName([x509.DNSName(u'localhost')]),
    critical=False,
).sign(key, hashes.SHA256())
 
# Save the private key
with open('key.pem', 'wb') as f:
    f.write(key.private_key_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),
    ))
 
# Save the certificate
with open('cert.pem', 'wb') as f:
    f.write(cert.public_bytes(serialization.Encoding.PEM))
 
print('[+] Certificate and Key generated!')

Run it: python generate_cert.py.

Building the Server

Now for the main event. We are going to build a server that handles the QUIC handshake and answers an HTTP/3 request.

9. The HTTP/3 Frame Logic

In HTTP/1.1, we sent plain text. In HTTP/3, we send encoded frames.

When a browser connects, it sends a HEADERS frame. This frame contains the HPACK/QPACK encoded headers (like the path /). Then it might send a DATA frame if it is a POST request.

Our server must:

  1. Receive the QUIC connection.
  2. Initialize an HTTP/3 state machine for that connection.
  3. Decipher the HEADERS frame.
  4. Send its own HEADERS frame (200 OK) and its own DATA frame (Hello World).

Setting Up the Base Server

Create a file named http3_server.py. We will start by setting up the logger and the configuration.

import asyncio
import os
from aioquic.asyncio import QuicConnectionProtocol, serve
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration
 
# Configuration for our QUIC server
configuration = QuicConfiguration(
    is_client=False,
    # Standard ports for certs we just made
    certificate_path='cert.pem',
    private_key_path='key.pem',
)

Handling the Events

Everything in aioquic is event-driven. We do not manually read from a socket like we did in the old server. Instead, the library tells us: 'Hey, I received some headers!' or 'Hey, the connection closed!'.

10. The Request Handler

We need a class that manages the life of a single connection.

Think of this class as a 'Waiter'.

  1. The Waiter greets the customer (Handshake).
  2. The Waiter takes the order (HeadersReceived).
  3. The Waiter brings the food (Send Headers + Data).
  4. The Waiter clears the table (ConnectionClosed).
class Http3Handler(QuicConnectionProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Each connection gets its own H3 instance
        self._h3 = H3Connection(self._quic)
 
    def quic_event_received(self, event):
        # A standard QUIC event (like packet received)
        # We pass it to the H3 state machine
        for h3_event in self._h3.handle_event(event):
            if isinstance(h3_event, HeadersReceived):
                self._handle_request(h3_event.stream_id, h3_event.headers)
 
    def _handle_request(self, stream_id, headers):
        # Extract the path from headers
        # Headers are a list of tuples: [(b':method', b'GET'), (b':path', b'/')...]
        headers_dict = {k: v for k, v in headers}
        path = headers_dict.get(b':path', b'/').decode()
        
        print(f'[+] Client requested: {path}')
 
        # Prepare the response body
        response_body = f'Welcome to HTTP/3! You asked for {path}'.encode()
 
        # 1. Send Response Headers (Status 200 OK)
        self._h3.send_headers(
            stream_id=stream_id,
            headers=[
                (b':status', b'200'),
                (b'server', b'python-aioquic-example'),
                (b'content-type', b'text/plain'),
                (b'content-length', str(len(response_body)).encode()),
            ],
        )
 
        # 2. Send Response DATA
        self._h3.send_data(stream_id=stream_id, data=response_body, end_stream=True)
        
        # 3. Flush packets to the network
        self.transmit()

Running the Main Loop

Finally, we need to tell Python to listen on a specific UDP port (usually 4433 for testing).

async def main():
    print('[*] Starting HTTP/3 Server on udp://localhost:4433')
    await serve(
        '127.0.0.1',
        4433,
        configuration=configuration,
        create_protocol=Http3Handler,
    )
    # This keeps the server running forever
    await asyncio.Future()
 
if __name__ == '__main__':
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

11. Understanding AsyncIO

Unlike our old server which used threading, this server uses asyncio.

In the threading model, if you have 1000 users, you need 1000 threads. Threads are heavy. They each take memory.

In the async model, there is only one thread. It works like a kitchen with one chef. The chef puts the pasta in the water, and while it boils, he chops the onions. He does not sit and stare at the pasta.

In our server, when we wait for a UDP packet, the CPU is free to do other things. This allows a single script to handle thousands of users without crashing.

The Transmit function

The self.transmit() call is vital. In TCP, the kernel handles when to send data. In QUIC, the application (us) has more control. transmit() tells the library: 'Okay, I have queued up all my responses, now turn them into UDP packets and send them out!'.

12. Handling Streams and IDs

QUIC uses IDs to keep track of conversations.

  • Even numbers (0, 2, 4...) are for things the client starts.
  • Odd numbers (1, 3, 5...) are for things the server starts (like Server Push, but that is advanced).

When a browser opens your site, it starts Stream 0 for index.html. If it needs styles.css, it starts Stream 4.

Our code inside _handle_request uses the stream_id provided by the client. This ensures our response goes back to the correct file request! If we messed this up, the browser would get the CSS file data but think it was the HTML.

Verification and Advanced Tuning

You have a server. Now you need to prove it works.

13. Testing with a QUIC Client

You cannot use a standard browser by just typing the URL yet. Why? Because browsers do not know your server supports HTTP/3. They usually try HTTP/1.1 first, then see an 'Alt-Svc' header (Alternative Service) that tells them: 'Hey, I have a faster server on port 4433!'.

To test directly, we need a specialized client.

Using curl with HTTP/3

Most versions of curl do not have HTTP/3 built-in because it requires special TLS libraries (like BoringSSL or QuicTLS).

If you have a modern Mac with Homebrew, you might be able to install a version that supports it. But a better way is to use the experimental client provided by aioquic.

# Run this in a second terminal window
python -m aioquic.examples.http3_client --ca-certs cert.pem https://localhost:4433/

This client is 'smart'. It knows how to use the cert.pem we made so it does not complain about security.

14. The Alt-Svc Header

If you want a real browser (like Chrome) to use your server, you need to tell it about it.

When a browser visits your site over HTTP/1.1, you send this header: Alt-Svc: h3=':4433'; ma=86400

This tells the browser: 'For the next 24 hours (86400 seconds), you can find an HTTP/3 version of this site on port 4433'.

The next time the user types your URL, the browser will skip the TCP handshake entirely and go straight to the QUIC port.

15. Handling Connection Migration (The Wi-Fi Switch)

This is the 'magic' part of QUIC.

In our Http3Handler, the library is doing a lot behind the scenes. When a packet arrives from a new IP address but has the same Connection ID, the library updates the connection state automatically.

Your code in _handle_request does not even need to change. It just keeps processing data. This is why HTTP/3 feels so robust on mobile phones.

Probing

Wait, how does the server know the new IP address isn't a hacker?

QUIC uses Path Validation. The server sends a random number (a CHALLENGE) to the new address. If the client can repeat that number back, the server knows: 'Okay, the person who had the Connection ID really is at this new address'.

16. Server Push in HTTP/3

In the old days, if a browser wanted a page, it asked for index.html. It received it, read it, and saw it needed main.css. So it sent a second request.

With Server Push, the server says: 'I know you are going to ask for main.css anyway, so here it is!'.

In our _handle_request function, we could start a new stream (an odd-numbered one) and send the CSS data before the client even asks for it.

Note: Browsers are starting to disable Server Push because it is hard to get right, but it is a powerful feature of the protocol.

17. Congestion Control in User Space

TCP congestion control is handled by your Windows or Mac kernel. If it is slow, you cannot fix it.

QUIC congestion control is handled by the application. This means if researchers find a faster way to handle congestion, they can just update the server software. You do not have to wait for a Microsoft or Apple update to get a faster internet.

aioquic implements several algorithms (like New Reno). It watches how many packets are getting lost and slows down or speeds up automatically to match your internet speed.

18. Scaling your Server

Our current script is just a toy. For a real production server, you would need:

  1. Multiple Workers: Use the multiprocessing module to run one server instance per CPU core.
  2. Certificate Management: Integrating with acme for automatic renewals.
  3. Static File Handling: Instead of just sending strings, you would use aiofiles to read from the disk without blocking the async loop.

19. The Future: HTTP/3 is here

Almost 30 percent of the internet is already using HTTP/3. YouTube, Facebook, and Google are leading the charge.

By building this server, you have moved past 'Web Development' and into 'Protocol Engineering'. You now understand how the packets move, how security is negotiated, and how to beat the limitations of the physical wires.

Final Checklist

  • Understand HOL Blocking.
  • Generate TLS certificates.
  • Set up an async loop.
  • Handle HeadersReceived events.
  • Send H3 Data frames.
  • Test with a QUIC client.

You are now a master of the modern web stack. Keep experimenting, keep building, and never stop digging into the layers.


Appendix: Full Server Source Code

Below is the complete, combined script for your reference.

import asyncio
from aioquic.asyncio import QuicConnectionProtocol, serve
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
 
class Http3Handler(QuicConnectionProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._h3 = H3Connection(self._quic)
 
    def quic_event_received(self, event):
        for h3_event in self._h3.handle_event(event):
            if isinstance(h3_event, HeadersReceived):
                self._handle_request(h3_event.stream_id, h3_event.headers)
 
    def _handle_request(self, stream_id, headers):
        headers_dict = {k: v for k, v in headers}
        path = headers_dict.get(b':path', b'/').decode()
        
        body = f'Hello from the QUIC world! Path: {path}'.encode()
 
        self._h3.send_headers(
            stream_id=stream_id,
            headers=[
                (b':status', b'200'),
                (b'content-type', b'text/plain'),
                (b'content-length', str(len(body)).encode()),
            ],
        )
        self._h3.send_data(stream_id=stream_id, data=body, end_stream=True)
        self.transmit()
 
async def main():
    configuration = QuicConfiguration(
        is_client=False,
        certificate_path='cert.pem',
        private_key_path='key.pem',
    )
    await serve('127.0.0.1', 4433, configuration=configuration, create_protocol=Http3Handler)
    await asyncio.Future()
 
if __name__ == '__main__':
    asyncio.run(main())

Keep Reading

Build Your Own HTTP Server

February 10, 2026 (1mo ago)
NetworkingPython24 min read

EKS Networking Deep Dive: Why Your Pods Can't Talk

January 18, 2026 (2mo ago)
KubernetesEKS8 min read

Kiro IDE: Building a Production API With Spec-Driven AI (Hands-On Tutorial)

April 1, 2026 (1w ago)
AWSDev Tools35 min read
Ranti

Rantideb Howlader

Author

Connect
LinkedIn