WebSocket Load Balancing: Sticky Sessions, Proxy Config, and Common Failures
WebSocket load balancing, optional session affinity, NGINX and AWS ALB configuration, and recovery across server restarts.
Scaling a WebSocket service requires a proxy that supports upgrades, appropriate idle timeouts, and a way to distribute messages across servers. Session affinity may also be needed when the application uses related HTTP requests or a polling fallback.
How WebSocket Connections Are Routed
HTTP load balancers distribute requests across backend servers using round-robin or least-connection algorithms. Each request is independent — it can go to any available server, and the server does not need to remember anything about previous requests from the same client.
WebSockets are stateful. When a client connects, the server builds an in-memory record of that connection: its socket reference, the user's authenticated identity, which rooms it has joined, and any session-specific state. All subsequent messages from that client must reach the same server — the one holding the socket object and the connection state.
After the upgrade, frames travel over the established connection to its selected backend. A load balancer does not independently round-robin each frame. New connections and HTTP polling requests are separate routing decisions.
When Sticky Sessions Are Needed
Session affinity keeps related requests on the same backend. An established WebSocket already stays on its selected connection; affinity is especially relevant for transports such as Socket.IO’s HTTP long-polling fallback. See Socket.IO’s multi-node guidance.
There are two common methods:
IP Hash — the load balancer hashes the client's IP address and consistently routes that IP to the same backend. Simple to configure, but fails when many users share a single IP (corporate NAT, proxies, CGNAT). All users behind that IP land on the same server, defeating the load-balancing purpose.
Cookie-based affinity — the load balancer sets a session cookie on the first request. Subsequent requests from that client carry the cookie, and the load balancer uses it to route to the correct backend. More reliable than IP hash in most production environments.
NGINX Configuration for WebSocket Load Balancing
A minimal NGINX upstream configuration for WebSocket sticky sessions using IP hash:
upstream ws_backend {
ip_hash;
server app1.internal:3000;
server app2.internal:3000;
server app3.internal:3000;
}
server {
listen 443 ssl;
server_name api.example.com;
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}The critical headers are Upgrade and Connection "upgrade" — without these, NGINX will not pass the WebSocket upgrade request to the backend. proxy_read_timeout must be set high enough to accommodate long-lived idle connections; the default NGINX timeout of 60 seconds will close connections that have not received data recently.
AWS ALB Configuration
On AWS Application Load Balancer, enable target-group stickiness when your application needs affinity across separate requests:
- Navigate to your target group in the EC2 console
- Edit target group attributes
- Enable "Stickiness" and set the stickiness type to "Load balancer generated cookie"
- Set the duration long enough to cover a typical session (several hours to days)
ALB supports WebSocket upgrades. Backend target groups can use HTTP or HTTPS; TLS termination at the load balancer does not require an unencrypted backend connection. See AWS’s target-group documentation.
Note that ALB has an idle connection timeout of 60 seconds by default. Increase this (up to 4000 seconds) or ensure your WebSocket heartbeat interval is shorter than the timeout. A heartbeat every 30 seconds with a 120-second ALB timeout is a reasonable combination.
Kubernetes Ingress for WebSocket Traffic
Configure WebSocket proxying and idle timeouts for your ingress controller. Add affinity only where the application’s transport or session model needs it.
For NGINX Ingress Controller:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: websocket-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "ws-affinity"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
spec:
rules:
- host: api.example.com
http:
paths:
- path: /ws
pathType: Prefix
backend:
service:
name: websocket-service
port:
number: 3000The affinity: cookie annotation enables cookie-based sticky sessions at the ingress layer.
The Limits of Sticky Sessions
Affinity does not preserve a connection when its server restarts. Clients must reconnect and may reach a different instance, which needs enough information to restore their subscriptions.
Use shared messaging and durable state for data that must survive server loss. After reconnecting, clients should re-authenticate and restore subscriptions. Decide explicitly which transient state can be rebuilt and which history must be recovered.
A Common Failure Mode: The Proxy That Drops Upgrades
Some corporate proxies, reverse proxies misconfigured without the Upgrade header passthrough, and certain CDN configurations silently drop WebSocket upgrade requests. The client receives a 200 OK for the initial HTTP request but the connection never upgrades, or it upgrades and then drops silently after 30 seconds.
Diagnose this by checking the connection lifecycle in your browser's Network tab:
- Look for the initial HTTP request to your WebSocket endpoint
- Check its status code (should be
101 Switching Protocols) - If you see
200or400, the upgrade failed at a proxy layer - Check the response headers for the presence of
Upgrade: websocketandConnection: upgrade
If you cannot fix the proxy, falling back to SSE or long polling for affected clients is the pragmatic path.