HTTP/3’s QUIC protocol is designed to be inherently more resilient to network issues than TCP, but this resilience can sometimes mask underlying load balancing problems.
Let’s see how a load balancer handles QUIC traffic in practice. Imagine a simple setup: a single load balancer fronting two backend web servers.
# nginx.conf snippet
stream {
server {
listen 443 quic reuseport;
listen 443 ssl http2; # For fallback or mixed environments
proxy_protocol on;
ssl_certificate /etc/ssl/certs/mycert.pem;
ssl_certificate_key /etc/ssl/private/mykey.pem;
ssl_session_tickets off; # QUIC doesn't use session tickets
quic_retry on; # Crucial for initial handshake
quic_session_migration off; # Typically off for load balancing
proxy_ssl on; # If backends expect SSL
proxy_ssl_name backend.example.com;
proxy_ssl_server_name on;
set_real_ip_from 192.168.1.0/24; # For proxy_protocol
real_ip_header proxy_protocol;
proxy_pass quic://backend1.internal:443,quic://backend2.internal:443;
# Or for more control:
# upstream quic_backends {
# server backend1.internal:443;
# server backend2.internal:443;
# policy least_conn; # Or round_robin, random
# }
# proxy_pass quic://quic_backends;
}
}
This configuration tells Nginx to listen for QUIC (and HTTP/2 for fallback) on port 443. reuseport is important for allowing multiple worker processes to bind to the same port, which is common in high-performance network applications. proxy_protocol on is essential if your backends need to see the original client IP address, as QUIC’s UDP nature doesn’t inherently carry this information across NATs or load balancers.
The quic_retry on directive is a handshake mechanism where the load balancer can respond to initial client packets with a "retry" token, preventing amplification attacks. quic_session_migration off is generally recommended in load-balanced environments. QUIC allows clients to migrate between IP addresses (e.g., Wi-Fi to cellular), but if the load balancer is stateful or uses sticky sessions, this migration could break. For stateless load balancing, disabling it simplifies things.
The proxy_pass directive then forwards the QUIC traffic. You can list backends directly or define an upstream block for more sophisticated load balancing policies like least_conn (directs traffic to the server with the fewest active connections) or round_robin (distributes connections sequentially).
The problem QUIC load balancing solves is efficiently distributing UDP-based, connectionless traffic that has multiplexed streams, all while handling the complexities of its handshake and encryption. Unlike TCP, where a connection is established and maintained, QUIC connections are established on top of UDP datagrams. The load balancer needs to track these UDP flows, often using the 0-RTT or 1-RTT handshake information to identify which backend a client is communicating with, or simply by distributing the initial UDP packets.
The most surprising true thing about QUIC load balancing is that while QUIC is designed for resilience, a poorly configured load balancer can still create significant bottlenecks by not properly distributing the initial UDP packets or by mishandling the handshake.
The real magic happens in how the load balancer handles the initial Initial packets from the client. It needs to decide which backend to send this first UDP datagram to. For stateless load balancers, this is typically a simple hash of the source IP and port, or a round-robin assignment. If proxy_pass is used with multiple servers, Nginx’s stream module will use a round-robin policy by default. When using an upstream block, you can explicitly define round_robin, least_conn, random, or hash policies. The hash policy is particularly useful for ensuring that a client’s subsequent packets from the same source IP and port consistently go to the same backend, mimicking sticky sessions without the statefulness.
The core challenge is that UDP is connectionless. The load balancer doesn’t get a SYN-ACK handshake like TCP. It receives UDP datagrams. For QUIC, the initial datagrams contain handshake information. The load balancer either forwards these raw UDP packets to a backend, or it can terminate the QUIC handshake itself (acting as a QUIC proxy). Most modern load balancers, like Nginx with the stream module, act as UDP forwarders, meaning they just pass the UDP packets along. The key is ensuring that subsequent packets from the same client flow to the same backend. This is typically achieved by hashing the client’s IP address and port, or by using the least_conn policy at the UDP layer.
What most people don’t realize is that the QUIC handshake itself can be a point of contention. The initial Initial packet can be spoofed, leading to amplification attacks. Load balancers use mechanisms like quic_retry to mitigate this by challenging the client to prove it’s not a bot. If the load balancer is configured to terminate QUIC (which is less common for UDP forwarding), it takes on the burden of performing the TLS 1.3 handshake for every new connection, which can be CPU-intensive. For UDP forwarding, the load balancer’s primary job is efficient UDP packet distribution and, if necessary, tracking flows for specific policies like least_conn.
The next hurdle you’ll encounter is managing QUIC connection IDs and potential packet loss across your backend infrastructure.