HTTP/3 behind an AWS network load balancer

HTTP/1.1 and HTTP/2 on the load balancer you already have, HTTP/3 on a second one beside it, and one deployment behind both. What AWS requires, why it requires it, and what it costs.

A network load balancer can carry QUIC, and since late 2025 it can route it properly: rather than hashing the client's address and port, it reads a server ID out of each packet's connection ID and sends it to the target that owns it. That is what makes HTTP/3 survive a client changing network, which is the whole point of the protocol's connection migration.

The cost is that the targets have to cooperate. A QUIC listener drops any connection ID that is not exactly seventeen bytes, without a word, so SSLB3 has to mint them in the layout the load balancer expects. Everything on this page follows from that, and from two constraints AWS places on which load balancer may carry QUIC at all.

HTTP/3 behind a second, QUIC-only network load balancerClients reach HTTP/1.1 and HTTP/2 through the existing dualstack network load balancer on TCP 443, which speaks PROXY protocol to the pods. Alt-Svc points them at a second, IPv4-only load balancer with no security group, carrying QUIC on UDP 443 to the same pods. Both load balancers target one deployment; the QUIC one routes by the server ID inside each connection ID, so a client that changes address still reaches the pod holding its connection.clientsHTTP/1.1 · 2HTTP/3Alt-SvcNLB · TCP 80/443dualstack · security groupNLB · QUIC 443IPv4 only · no security groupPROXY protocolclient IP preservedone deploymentpod 18443/tcp · 8444/udpid 1pod 28443/tcp · 8444/udpid 2pod 38443/tcp · 8444/udpid 3

Why there are two of them

A QUIC listener cannot sit on a load balancer that is dualstack or that carries a security group. Most existing ones are both, and giving either up to gain HTTP/3 is a bad trade: dropping dualstack takes IPv6 away from HTTP/1.1 and HTTP/2, which is the traffic that actually arrives.

So HTTP/3 goes on a second load balancer — IPv4 only, no security group, one QUIC listener — and clients are told about it with Alt-Svc. It points at a name of its own, and that name needs neither a certificate nor an Ingress: RFC 7838 changes where a service is found, not what it is called. The client keeps sending the origin in SNI and :authority, and still wants a certificate for the origin.

One deployment, not two

Both load balancers target the same pods. The QUIC listener runs in the same container as the TCP one, shares its certificates, its routing, its intrusion detector and its configuration, and differs only in the port it binds. A second deployment would double the pods and split every one of those in half.

What makes that work across replicas is that each pod gets a different server ID, which the load balancer controller injects at pod creation and registers with the target group. A client's connection ID therefore names the pod holding its connection, whatever its address does afterwards.

PortWhat it is

443/tcp

The existing load balancer’s listener, forwarding to 8443 in the container with PROXY protocol so the client’s address survives.

443/udp

The QUIC listener on the second load balancer. No PROXY protocol: an NLB prepends that header to every datagram rather than once per flow, and QUIC passthrough preserves the client’s address natively.

8443/udp

The Service’s own HTTP/3 port. A number of its own rather than sharing 443 — see below.

8444/udp

What the container binds for HTTP/3, beside 8443/tcp for HTTPS.

6444/tcp

The probe endpoint both target groups health check, so a target is only in service once SSLB3 has a configuration to serve.

The UDP numbers differ from the TCP ones deliberately. Kubernetes tells a TCP port from a UDP one on the same number, but Helm's client-side patch keys ports on the number alone — so two entries sharing 443 are one entry to it, and removing one removes the other. The chart refuses to render a collision rather than let that happen.

The connection ID, and the two things that read it

Seventeen bytes, in the QUIC-LB draft's layout: a first octet, an eight-byte server ID, and a nonce. SSLB3 writes the worker index into the first byte of that nonce, which is what the in-pod socket filter reads.

What a routable connection ID carriesSeventeen bytes: a first octet whose top three bits must not be all ones, an eight-byte server ID the load balancer routes on, a byte holding the worker index that the in-pod socket filter reads, and a nonce. The load balancer picks the pod from the server ID; classic BPF inside that pod picks the worker from the next byte.connection ID · 17 bytesfirst octetserver ID · 8 bytesworker · 1noncethe NLBpicks the podclassic BPF in the podpicks the worker socketneither reads the client's address, which is what lets a moved client keep its connection

The top three bits of the first octet are config rotation, and 0b111 means "unroutable" — a load balancer seeing it falls back to hashing the address. Leaving that byte fully random therefore sends about one connection in eight down the hashed path. Measured against a real load balancer: with a random first octet, 13.4% of packets on the routable path were hashed instead of routed; with the bits cleared, none were. SSLB3 clears them.

The kernel-assisted half, inside the pod

Routing to the right pod is only half the problem. Inside it, HTTP/3 gets its throughput from one socket per worker sharing the port through SO_REUSEPORT — and the kernel picks between those sockets by hashing the packet's address. A client that changes address therefore lands on a worker that has never seen its connection, and the connection dies a few metres from home.

ConnectionMigration = yes attaches a classic BPF program to the reuseport group with SO_ATTACH_REUSEPORT_CBPF. Seven instructions: read the first byte, test the long-header bit, and for a short-header packet return the byte holding the worker index modulo the number of sockets. Long headers — handshakes, before any connection ID has been agreed — fall back to the kernel's hash.

It is Linux-only, and the configuration refuses it elsewhere rather than accepting it and doing nothing. It also needs ConnectionIdFormat = quic-lb: without a worker index in the connection ID there is nothing for the filter to read.

What it costs

Measured on a six-node cluster, one pod at a 4 CPU limit, generator and backends on separate nodes, 1000 connections for 45 seconds, with the balancer CPU-bound in every row:

HTTP/3, native routingreq/sp50p99

steering off

44,283

16.95ms

57.83ms

steering on

47,679

16.26ms

56.66ms

steering on, every connection moving every 2s

46,047

19.00ms

57.23ms

The filter costs nothing measurable, and the load stays even: across the four HTTP/3 threads the busiest-to-least-busy ratio was 1.00 in every run. The last row is 22,334 migrations in 45 seconds, of which 31 failed — 0.14%. With steering off, the same test does not produce a number at all: stranded connection state accumulates until the balancer is killed for exceeding its memory limit.

What to configure

The Helm values, in the order they matter. The chart's defaults are right for a deployment reached directly; everything here is what changes behind a load balancer.

values.yaml

config:
  listeners:
    h3:
      enabled: true
      # Withhold Alt-Svc while you prove the path. Nothing finds HTTP/3
      # without it, so curl can reach it and browsers cannot.
      advertise: false
      # The name of the QUIC load balancer, and the port clients reach it on.
      advertiseHost: h3.example.com
      advertisePort: 443
      # Short while this is new: a client caches the answer for this long.
      advertiseMaxAge: 300
      # 1 + 8 + 8 = 17 bytes, the only length a QUIC listener accepts.
      connectionIdFormat: quic-lb
      serverIdEnvironment: AWS_LBC_QUIC_SERVER_ID
      serverIdEncoding: base64
      # The in-pod half. Linux only, and needs quic-lb above.
      connectionMigration: true

The server ID comes from the environment because it differs per pod. The load balancer controller injects it into a container named by a pod annotation, in a namespace carrying a label:

injection.yaml

# on the namespace
elbv2.k8s.aws/quic-server-id-inject: enabled

# on the pod
service.beta.kubernetes.io/aws-load-balancer-quic-enabled-containers: sslb3

The target group

A TargetGroupBinding naming targetGroupProtocol: QUIC registers the pods' addresses and the server ID each one was injected with. Leaving the protocol to be inferred registers the addresses and none of the IDs, and the load balancer then has nothing to route on.

Two settings on the load balancer itself are not optional. Cross-zone load balancing must be on: a node that cannot reach the target holding a server ID drops the packet rather than forwarding it, and since handshakes are hashed rather than routed, that fails only after a connection is established — for some clients, silently. And the pods' security group must admit UDP from clients directly, because QUIC passthrough preserves the client's address and the QUIC load balancer has no security group of its own to reference.

What to watch

QUIC_Unknown_Server_ID_Packet_Drop_Count

The load balancer dropping packets it cannot place. Anything above zero means the connection IDs and the registered server IDs disagree — the silent failure this layout exists to avoid. Alert on it.

sslb3_listener_requests_total

By family and version. The HTTP/3 share rising after you advertise is the feature working; an IPv6 share that collapses when it rises is clients moving to the v4-only path.

the two startup lines

One says the listener issues routable connection IDs carrying a server ID, and whether it is writing a worker index. The other says the steering filter is attached. Both are INFO, at startup, once per listener.

What this does not do

Deregistering a target does not end established QUIC flows. They were measured still working 394 seconds after deregistration, past the fixed 300-second delay, so a rollout leaves some HTTP/3 clients talking to a pod that is going away until they give up and fall back. SSLB3 sends an HTTP/3 GOAWAY on shutdown, which is what a well-behaved client acts on; the ones that do not will reconnect.

And the two halves are independent. Routable connection IDs settle which pod a moved client reaches, and are what a load balancer in front needs; the socket filter settles which worker inside that pod. Turning on only the filter still breaks the moment there is more than one replica, which is why it is off by default.