Load Balancer Sticky Sessions for WebSocket #
A user opens your collaborative editor, types for ten minutes against backend node-3, then a brief network blip triggers a reconnect. The load balancer, free to pick any healthy target, lands them on node-7. Their in-memory document cursor, presence color, and unacked edit buffer all lived on node-3 — and now they are gone. The client sees a blank canvas, fires a full resync, and you watch a spike of duplicate GET /document/:id hammering your database. This is the failure that session affinity exists to prevent: when WebSocket state lives in process memory, the routing layer must pin each client to the same backend instance for the life of the connection — and, ideally, across reconnects.
This page covers the L4/L7 routing tier: how nginx, HAProxy, and AWS ALB implement affinity for long-lived upgraded connections, how to preserve the Upgrade handshake through the proxy, and how to fail over without corrupting state. Application-level connection handling sits one layer up in Backend WebSocket Connection Management.
Prerequisites #
Before configuring affinity, make sure the following are already in place:
- A working WebSocket upgrade path through your proxy. Header preservation (
Upgrade/Connection) is covered in Configuring nginx for WebSocket upgrades; affinity without a clean upgrade is meaningless. - Server-side heartbeats so the routing layer and the application agree on liveness — see Connection Lifecycle & Heartbeats. Affinity drift is invisible until a half-open connection is detected.
- A client reconnect policy with backoff, since affinity will break during deploys — see Auto-Reconnection Strategies.
- An honest audit of what state is actually in-process. If everything you need is already in Redis or Postgres, you may not need affinity at all (see the first gotcha below).
How affinity pins a client to a backend #
The hard part of sticky routing is not the happy path — it is what happens on the reconnect. A new TCP connection arrives with no inherent memory of where the last one went; affinity reconstructs that memory from a cookie or a hash of the client. The diagram below traces both: the first connection establishes the pin, and the reconnect honours it.
Core implementation #
Affinity is configured at the proxy, but the application still has to cooperate at shutdown so the pin can be released cleanly. The TypeScript below runs alongside your ws server: it tracks live sockets and, on SIGTERM, tells clients to reconnect before the proxy yanks the target — which is the difference between a graceful failover and a wall of 1006 abnormal closures.
import { WebSocketServer, WebSocket } from "ws";
const DRAIN_GRACE_MS = 2_000; // give clients time to ack the drain notice
const CLOSE_GOING_AWAY = 1001; // RFC 6455: endpoint going away (deploy/restart)
const wss = new WebSocketServer({ port: 8080 });
const liveSockets = new Set<WebSocket>();
wss.on("connection", (ws) => {
liveSockets.add(ws);
ws.on("close", () => liveSockets.delete(ws)); // keep the set honest
});
// During a rolling deploy the orchestrator deregisters this target, then SIGTERMs us.
// We must push clients off BEFORE the LB stops sending traffic, or affinity breaks mid-flight.
process.on("SIGTERM", () => {
for (const ws of liveSockets) {
if (ws.readyState !== WebSocket.OPEN) continue;
// Tell the client to back off and reconnect — it will land on a healthy target
ws.send(JSON.stringify({ type: "SERVER_DRAIN", reconnectInMs: DRAIN_GRACE_MS }));
ws.close(CLOSE_GOING_AWAY, "draining"); // clean close => no 1006 on the client
}
// Exit only after the grace window so in-flight frames flush
setTimeout(() => process.exit(0), DRAIN_GRACE_MS);
});
The proxy side is where the actual pinning happens. Three common deployments follow.
nginx (open-source) — ip_hash affinity. The sticky cookie directive ships only with nginx Plus or the third-party nginx-sticky-module-ng. On stock nginx, ip_hash is the no-module fallback — it hashes the client address to a backend, so the same client returns to the same node as long as its source IP is stable.
upstream ws_backend {
ip_hash; # pin by client IP (first 3 octets for IPv4)
server 10.0.1.10:8080;
server 10.0.1.11:8080;
}
server {
location /ws/ {
proxy_pass http://ws_backend;
proxy_http_version 1.1; # required for upgrade
proxy_set_header Upgrade $http_upgrade; # preserve the handshake
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400s; # don't kill idle long-lived sockets
}
}
HAProxy — cookie-based affinity with leastconn. This is the most precise of the three: HAProxy injects a SERVERID cookie naming the chosen server, and subsequent requests carrying it skip the balancer and go straight back. leastconn then governs only new, unpinned connections — the right load metric for long-lived sockets, since round-robin counts requests, not concurrent connections.
backend ws_nodes
balance leastconn # new conns -> least-loaded node
cookie SERVERID insert indirect nocache # inject affinity cookie, don't cache it
option http-server-close
option forwardfor # add X-Forwarded-For for the app
server node1 10.0.1.10:8080 check cookie node1 # cookie value pins to this server
server node2 10.0.1.11:8080 check cookie node2
AWS ALB — application cookie stickiness (Terraform). Managed L7 balancers preserve the upgrade automatically, but stickiness and timeouts must be set explicitly. The deep walkthrough — idle-timeout math, health checks, and draining — is in Configuring AWS ALB for WebSocket sticky sessions.
resource "aws_lb_target_group" "ws_sticky" {
name = "ws-realtime-sync"
port = 8080
protocol = "HTTP" # ALB upgrades HTTP listeners to WS transparently
vpc_id = var.vpc_id
stickiness {
type = "app_cookie" # honour an app-set cookie, not a duration cookie
cookie_name = "WS_SESSION_ID"
cookie_duration = 3600
enabled = true
}
deregistration_delay { # let live sockets drain before removal
timeout_seconds = 300
}
health_check {
path = "/health"
matcher = "200"
interval = 15
healthy_threshold = 2
unhealthy_threshold = 3
}
}
Configuration reference #
| Parameter | Type | Default | Production value | Notes |
|---|---|---|---|---|
proxy_read_timeout (nginx) |
duration | 60s |
86400s |
Below this, idle WebSockets are killed by the proxy, not the app. |
balance (HAProxy) |
enum | roundrobin |
leastconn |
Round-robin counts requests; long-lived sockets need connection-count balancing. |
cookie ... insert indirect nocache |
directive | off | on | Injects SERVERID; nocache stops shared caches from leaking it. |
| ALB idle timeout | seconds | 60 |
≥90 (or 3600) |
Must exceed your heartbeat interval, or the ALB drops the socket. |
ALB stickiness.type |
enum | lb_cookie |
app_cookie |
App cookie survives target-group changes and is app-controlled. |
deregistration_delay.timeout_seconds |
seconds | 300 |
300+ |
Window for live sockets to drain on deploy before forced close. |
ip_hash (nginx) |
flag | off | situational | Breaks for clients behind rotating NAT/CGNAT; prefer cookies when available. |
Edge cases & gotchas #
- You may not need affinity at all. If your in-process state is only ephemeral (presence color, cursor position) and everything durable already lives in Redis or Postgres, sticky routing buys you nothing and costs you balance. Externalize state and route freely — that is the path taken by horizontal scaling on Kubernetes, where pods are deliberately interchangeable. Audit before you pin.
ip_hashcollapses behind NAT and mobile networks. Carrier-grade NAT puts thousands of users on one egress IP, so they all hash to one node, while a client roaming WiFi-to-cellular changes IP and loses its pin mid-session. Cookie affinity is immune to both; reach forip_hashonly when you genuinely cannot set a cookie.- Affinity is not failover. A cookie pins to a node that may have died. The proxy must health-check and re-pin on failure rather than blackholing the client. On a hard upstream failure, close the socket with
1011and let the client reconnect cleanly instead of silently re-routing the same cookie to a cold node. - Idle timeout shorter than heartbeat = phantom drops. If the ALB idle timeout (60s) is below your heartbeat interval, the balancer severs sockets the app still believes are healthy. Always set the LB idle timeout above the ping interval, never the reverse.
Verification #
Confirm the upgrade, the pin, and the balance with these checks:
# 1. Confirm the WebSocket actually upgraded through the proxy (look for 101)
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZQ==" -H "Sec-WebSocket-Version: 13" \
https://example.com/ws/
# 2. Reconnect twice and confirm the SERVERID cookie pins to the same node
curl -i https://example.com/ws/ | grep -i 'set-cookie: SERVERID'
# 3. Count established WebSocket sockets per backend to spot affinity skew
ss -tnp state established '( dport = :8080 )' | wc -l
In Prometheus, assert that no node holds a disproportionate share of connections — a sign the pin is concentrating load on a single target:
# Fire when any node carries > 40% of all active connections
max(
sum by (node) (ws_connections_active)
/ scalar(sum(ws_connections_active))
) > 0.40
Pair this with browser DevTools: open the Network tab, filter to WS, reconnect, and verify the response carries the same affinity cookie value across attempts.
Guides in this area #
- Configuring AWS ALB for WebSocket sticky sessions — the platform-specific walkthrough: target-group stickiness, idle-timeout math against your heartbeat, health checks, and connection draining for zero-downtime deploys.
When affinity is required, and when it is a smell #
Sticky sessions are one of those mechanisms that quietly becomes load-bearing. It is worth separating the cases where affinity is genuinely necessary from the cases where it is compensating for state that should not be in the process.
Genuinely necessary. A reconnecting client that must resume from a server-side buffer needs the node holding that buffer. A fallback transport that emulates a socket over several HTTP requests needs every one of them to reach the same process. And a connection whose upgrade was authorised by a ticket stored in memory needs the node that issued it.
A smell. Per-connection application state kept only in the node’s heap — a user’s subscriptions, a room roster, a rate-limit bucket — makes affinity mandatory for correctness rather than convenience, and it does so silently. The system works until a pod restarts, at which point users lose state in ways that look like random bugs. The fix is to externalise the state, not to strengthen the stickiness.
The distinction matters because affinity has real costs. It defeats least-connections balancing, so load spreads by arrival rather than by concurrency and a recovered node stays empty while its peers stay full. It makes deploys more disruptive, because a drained node’s clients cannot simply land anywhere. And it creates a correlation between one node’s failure and one group of users’ experience, which is exactly the correlation you spend money to avoid elsewhere.
The pragmatic position is to design so that any node can serve any client, and then use affinity as an optimisation — clients prefer their previous node so resume is cheap, but nothing breaks when they land elsewhere. That gets the latency benefit without the fragility, and it is what makes a rolling deploy a non-event rather than a mass resynchronisation.
The other cost is visible after any node loss, and it is the reason least-connections balancing matters even when you do use affinity for new connections.
FAQ #
Do I need sticky sessions if I use Redis for state? #
Usually not. If every piece of state a reconnecting client needs is already in Redis or another shared store, any node can serve any client, and affinity only constrains your load balancing. Keep sticky routing for genuinely in-process state — unacked buffers, in-memory CRDT documents — that would be too latency-sensitive to externalize on every message.
Does sticky session affinity survive a WebSocket reconnect? #
Only if the affinity is cookie-based and the client resends the cookie. Browser WebSocket clients send cookies on the upgrade request automatically, so HAProxy SERVERID and ALB app_cookie affinity hold across reconnects. ip_hash survives only as long as the client’s source IP is stable, which fails on mobile networks.
Why use leastconn instead of round-robin for WebSockets? #
Round-robin balances by request count, but a WebSocket is one request that stays open for hours. After a deploy, round-robin sends an equal number of new connections to each node while ignoring that some nodes already hold thousands of live sockets. leastconn balances by concurrent connections, which is what actually loads a real-time backend.
How long should the load balancer idle timeout be? #
Strictly longer than your application heartbeat interval. A 30-second ping with a 60-second ALB idle timeout is safe; a 90-second ping with the same timeout will see the balancer drop sockets the app thinks are alive. For very quiet connections, raise the ALB idle timeout to 3600 seconds rather than relying on traffic to keep it open.
Does the WebSocket protocol need sticky sessions at all? #
No. Once the upgrade completes, the TCP connection is pinned to that process for its entire life regardless of what the load balancer thinks — there is nothing to route. Stickiness matters for what happens around the socket: the HTTP requests that authorise it, a reconnect that wants the same node, or a fallback transport that spans several requests.
Cookie stickiness or IP hashing? #
Cookies, where you can. IP hashing breaks whenever clients share an address — a corporate NAT, a mobile carrier gateway — which concentrates thousands of users on one node, and it re-shuffles every time the backend set changes. A cookie issued on a page load is present on the upgrade request and survives an IP change, which is exactly what a mobile client does when it moves between networks.
How does stickiness interact with a rolling deploy? #
Badly, unless the state is shared. A drained node’s clients must go somewhere, and if their session only exists on that node they resynchronise from scratch — for every user, on every deploy. This is the clearest practical argument for externalising per-connection state: it turns a deploy from a resynchronisation event into a reconnect, which users do not notice.
What is the least-bad option when the balancer cannot do stickiness? #
Route at the application layer instead. Have the client fetch its endpoint from an HTTP call that returns a node-specific hostname or a signed hint, then connect directly to that. It is more moving parts than a cookie, but it works on any balancer, survives IP changes, and gives you somewhere to put policy — a reconnect can be pinned briefly and then released, which no cookie-based scheme expresses cleanly.
Related #
- Configuring AWS ALB for WebSocket sticky sessions — ALB-specific stickiness, timeouts, and draining.
- Connection Lifecycle & Heartbeats — detecting half-open sockets so affinity drift becomes visible.
- Auto-Reconnection Strategies — backoff and jitter so reconnects after a failover don’t storm the routing layer.
- Horizontal Scaling on Kubernetes — the stateless alternative where pods are interchangeable and affinity is unnecessary.