HTTP/3, while promising lower latency and better performance, is surprisingly difficult to load test effectively.
Let’s see what happens when we throw some real traffic at it. Imagine you have a server configured to accept HTTP/3 connections on port 443. You want to simulate 1,000 concurrent users, each making a request every 5 seconds, to a specific endpoint, say /api/v1/users.
First, we’ll use h2load for a quick, focused benchmark. This tool is part of the nghttp2 library and is excellent for raw performance measurement.
h2load \
--clients=1000 \
--requests=20000 \
--duration=20s \
--max-concurrent-streams=100 \
--endpoints=https://your-h3-server.com:443/api/v1/users \
--proto=h3
This command spins up 1,000 clients, each attempting to make up to 20,000 requests within a 20-second window, with a limit of 100 concurrent streams per client. The --proto=h3 is crucial here. If your server supports HTTP/3, h2load will try to negotiate it.
Now, for a more realistic, distributed load test that simulates user behavior, we turn to Locust. Locust is a Python-based load testing tool that allows you to define user behavior in code.
Here’s a basic Locustfile for HTTP/3:
from locust import HttpUser, task, between
class Http3User(HttpUser):
wait_time = between(4, 6) # Users will wait between 4 and 6 seconds between tasks
host = "https://your-h3-server.com:443"
@task
def get_users(self):
# Locust's HttpUser client, by default, uses http2 if available and negotiated.
# For HTTP/3, the underlying libraries (like httpx) need to support it.
# We'll ensure it's attempted.
self.client.get("/api/v1/users", http2=True) # http2=True hints for HTTP/2 or HTTP/3
To run this with HTTP/3, you need to ensure your locust installation, and more importantly, the underlying HTTP client library (typically httpx or requests with httpx as a backend), has QUIC and HTTP/3 support compiled in.
Start Locust with:
locust -f your_locustfile.py --host=https://your-h3-server.com:443
Locust will then spawn workers (or run in standalone mode) that will attempt to connect to your server. If the server offers HTTP/3 and the client libraries support it, the connection will be upgraded. You can monitor the output for successful HTTP/3 connections.
The core problem this solves is that HTTP/3 runs over QUIC, which is UDP-based, unlike HTTP/1.1 and HTTP/2 which run over TCP. This fundamental shift requires different network handling and multiplexing, making it a departure from traditional socket programming. h2load and Locust (with appropriate libraries) abstract this away, but understanding the underlying QUIC handshake and UDP packet flow is key to debugging.
When you configure your load testing tool, you’re essentially telling it to initiate a QUIC connection. This involves a TLS handshake that’s integrated with the QUIC handshake. If this process fails, the client will likely fall back to HTTP/2 or even HTTP/1.1 if configured to do so, or simply report connection errors. The h2load --proto=h3 flag explicitly tells it to try for H3. In Locust, the http2=True parameter on self.client.get will attempt to use HTTP/2 or HTTP/3 if the underlying library supports and negotiates it.
The most surprising aspect is how often the issue isn’t the load testing tool itself, but the underlying network infrastructure or the TLS certificate configuration. Firewalls blocking UDP ports 443 (the standard for QUIC), or improperly configured TLS certificates that might work for TCP-based HTTP but not for the QUIC handshake, are common culprits. If your server’s TLS stack doesn’t correctly handle the google.com (or similar) initial handshake packets from the QUIC client, the connection will simply not establish.
The next challenge is understanding how to monitor and interpret QUIC-specific metrics, such as packet loss and retransmissions, which are surfaced differently than TCP’s SYN/ACK behavior.