HAProxy WebSocket Load Balancing Configuration #

Connections work for exactly 50 seconds and then die with code 1006, every time, on every client. Or the upgrade returns 200 OK with an HTML body instead of 101 Switching Protocols. Or everything works until you reload HAProxy for an unrelated change and 40,000 sockets drop at once. All three are configuration, and all three have precise fixes.

HAProxy handles WebSockets well — better than most reverse proxies — but only once you tell it that these connections are tunnels, not requests, and that its default timeouts were written for something else entirely.

Root cause #

HAProxy in HTTP mode is built around the request/response cycle. It reads a request, forwards it, reads a response, and applies timeout client and timeout server to each phase. A WebSocket breaks that model at the 101 response: after the upgrade there are no more requests, and a connection can legitimately sit silent for minutes.

Three defaults then work against you:

timeout tunnel is unset, so HAProxy falls back to the smaller of timeout client and timeout server for the tunnelled connection. With the common timeout client 50s, every WebSocket dies at 50 seconds of silence — which is exactly the symptom above, and why it looks like a heartbeat problem when it is not.

timeout http-keep-alive and timeout http-request still apply to the upgrade request itself, so a slow client can be cut off mid-handshake.

Reloads are not seamless by default. A plain systemctl reload starts a new process and stops the old one; without the right expose-fd and hard-stop settings, established tunnels are severed rather than allowed to drain.

Which timeout governs which phase Each phase of a WebSocket connection is governed by a different HAProxy timeout; after the 101 response only timeout tunnel applies, which is why leaving it unset causes connections to die at the client timeout. Which timeout governs which phase timeout http-request client must finish sending the upgrade request 5s timeout connect HAProxy to backend TCP connect 5s timeout client / server applies until the 101 response completes 50s timeout tunnel governs the whole connection AFTER the upgrade 1h hard-stop-after how long old processes keep tunnels alive on reload 30m If timeout tunnel is unset, HAProxy uses the smaller of client and server — the single most common WebSocket misconfiguration
Only the fourth band governs a live WebSocket. The first three are about getting there.

Resolution #

The configuration below routes WebSocket traffic explicitly, keeps tunnels alive for an hour, and pins each client to a backend so per-connection state stays valid.

global
log stdout format raw local0
# Let a reload inherit listening sockets so no connection is refused mid-swap
stats socket /var/run/haproxy.sock mode 660 level admin expose-fd listeners
# Old processes keep serving established tunnels for this long after a reload
hard-stop-after 30m

defaults
mode http
log global
option httplog
option http-server-close
timeout http-request 5s # the upgrade request itself must arrive quickly
timeout connect 5s
timeout client 50s # plain HTTP only — superseded by tunnel after 101
timeout server 50s
timeout tunnel 1h # THE important one: governs the live WebSocket
timeout client-fin 30s # do not hold half-closed connections forever
timeout tunnel-fin 30s

frontend web
bind :443 ssl crt /etc/haproxy/certs/site.pem alpn h2,http/1.1

# Detect a genuine upgrade: the Connection header is a comma-separated list,
# so match on the token rather than the whole value.
acl is_upgrade hdr(Upgrade) -i websocket
acl is_conn_upg hdr_reg(Connection) -i (^|,)[\s]*Upgrade([\s]*(,|$))
acl is_ws_path path_beg /ws

use_backend ws_nodes if is_upgrade is_conn_upg
use_backend ws_nodes if is_ws_path
default_backend http_nodes

backend ws_nodes
balance leastconn # NOT roundrobin: connections are long-lived
# Sticky routing so per-connection state on a node stays reachable across
# the upgrade and any HTTP calls the client makes alongside it.
cookie SRVID insert indirect nocache httponly secure samesite=Lax
option httpchk GET /healthz
http-check expect status 200

# Pass the real client through; the app needs these for origin checks and logs.
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-For %[src]

# Drain gracefully: a server in MAINT keeps existing tunnels, refuses new ones.
default-server check inter 5s fall 3 rise 2 on-marked-down shutdown-sessions

server ws1 10.0.1.11:8080 cookie ws1 maxconn 30000
server ws2 10.0.1.12:8080 cookie ws2 maxconn 30000
server ws3 10.0.1.13:8080 cookie ws3 maxconn 30000

Two lines deserve emphasis. balance leastconn is correct for WebSockets in a way that round-robin is not: round-robin distributes connection events evenly, which after any restart or network event means the node that was down receives nothing while the others stay loaded. Least-connections distributes the thing that actually costs you — concurrent sockets.

on-marked-down shutdown-sessions is a deliberate choice, not a default. It means that when a health check fails, HAProxy severs existing tunnels to that server rather than leaving clients attached to a node that has stopped working. Combined with jittered client reconnects, that is what you want; without jitter, it is a stampede, so make sure the client side follows exponential backoff with jitter first.

Symptom to setting A lookup table mapping each common HAProxy WebSocket symptom to the specific directive that causes it and the directive that fixes it. Symptom to setting Likely cause Fix Dies at ~50s idle timeout tunnel unset timeout tunnel 1h 200 OK, no 101 upgrade not routed ACL on Upgrade header 503 on upgrade health check failing httpchk on /healthz All sockets drop on reload no expose-fd listeners expose-fd + hard-stop-after Uneven node load balance roundrobin balance leastconn State lost between calls no affinity cookie SRVID insert Every one of these is a configuration default, not a bug — HAProxy's defaults are written for request/response traffic
Six symptoms, six directives. None of them require touching application code.

The leastconn choice is easiest to see after a node comes back. Round-robin hands the recovered node an equal share of new connections, which — because nobody is disconnecting — leaves it near-empty for hours, while least-connections drains the imbalance immediately.

Connection skew after a rolling restart Sockets per pod before and after draining 2 of 6 pods: the surviving pods absorb the reconnects and stay unbalanced until connections are rebalanced. Connection skew after a rolling restart 6 pods × 25k sockets — 2 drained, clients reconnect Before After 0 20k 40k 60k 25k pod 1 25k pod 2 25k 42k pod 3 25k 41k pod 4 25k 34k pod 5 25k 33k pod 6
Connections per node before and after draining two of six: the surviving nodes absorb everything, and only a concurrency-aware balancer moves the load back when the drained nodes return.

Verification #

Check the upgrade, the idle behaviour, and the reload separately.

# Does the upgrade actually reach the backend and return 101?
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://example.com/ws

# Live connection counts per backend server, and which are draining
echo "show stat" | socat /var/run/haproxy.sock stdio | cut -d, -f1,2,5,18

# Reload without dropping tunnels, then confirm old processes are still serving
systemctl reload haproxy && pgrep -a haproxy | head

The idle test is the one people skip: open a connection, send nothing for three minutes, and confirm it is still alive. If it dies near your timeout client value, timeout tunnel is not being applied — usually because it was set in a backend that the traffic is not actually reaching, which the show stat output will reveal.

Operational checklist #

FAQ #

Do I need mode tcp instead of mode http? #

No, and you usually do not want it. HTTP mode gives you path routing, header inspection, sticky cookies and useful logs, and it handles the upgrade correctly once timeout tunnel is set. TCP mode is the right answer only when you must pass traffic HAProxy cannot parse — for instance terminating TLS elsewhere and forwarding opaque bytes — and it costs you every routing feature above.

Why do connections still drop at 50 seconds after I set the timeout? #

Almost always because timeout tunnel was set in defaults but the traffic is served by a backend that overrides timeout server, or because it was set in the wrong section entirely. Check show stat to confirm which backend the connections land in, and remember that a proxy in front of HAProxy — a cloud load balancer, a CDN — has its own idle timeout that HAProxy cannot override.

How do sticky cookies interact with the WebSocket upgrade? #

The cookie is set on an earlier HTTP response and sent by the browser with the upgrade request, so affinity is established before the socket exists. That is why cookie stickiness works for WebSockets at all. If the client connects to wss:// as its very first request with no prior page load, there is no cookie yet — fall back to source-based balancing or issue the cookie from the page that opens the socket, as discussed in load balancer sticky sessions.

Is HAProxy’s health check enough to detect a broken node? #

For process liveness, yes. For WebSocket-specific failures — a node that accepts TCP but whose upgrade handler is wedged — an HTTP check on /healthz may pass while sockets fail. Make the health endpoint assert something real: that the WebSocket server is listening, that the fan-out bus is reachable, and that the node is below its connection budget.

How does this compare to nginx for WebSockets? #

Both work. HAProxy has better connection-level observability (show stat is unmatched), more precise timeout control, and genuinely seamless reloads. nginx is more common where the same server also terminates TLS for a static site and is covered in configuring nginx for WebSocket upgrades. The failure modes are near-identical; only the directive names differ.

Back to Load Balancer Sticky Sessions