← Tutorials

Sticky sessions, WebSockets and backend failure

Test cookie affinity with HAProxy, separate WebSockets from Socket.IO polling, and check what happens to a session when its backend fails.

By The Downtime · Jul 1, 2026 · 1:30 PM ·Updated 1 week ago
Sticky sessions, WebSockets and backend failure

Sticky sessions keep a client's later requests on the backend chosen for its first request. That can keep a legacy login working when session data lives on one server. If that server fails, the next backend still needs a way to recover the user's state.

An open WebSocket already stays on its connection. Cookie affinity becomes relevant when separate requests need the same process, such as Socket.IO's HTTP polling transport. Mixing those cases leads to unnecessary stickiness and incomplete failure plans.

Find the state that needs a home

Start with one request that works on server A and fails on server B. Find what B is missing. It might be a login session, an upload in progress, or transport state owned by A's process. A routing cookie only tells the proxy where to send the request. It does not copy that state.

Shared session storage can remove the dependency on one backend, provided every process can read the same data and use compatible session settings. Django defaults to database sessions. Its documentation warns against the local-memory cache for sessions because it is not safe across processes. Pinning traffic to a host does not fix that host's separate worker caches. See Django's session backend guidance.

Keep affinity when software requires it and you cannot yet change the state storage. Write down the accepted failure: a fresh login, an abandoned upload, or a reconnect with replay. A cache that merely saves work deserves a measurement before you pin every user to one machine.

WebSockets and Socket.IO have different requirements

A WebSocket connection carries later frames over the connection established by the upgrade. On AWS Application Load Balancer, the target that accepts the upgrade keeps that connection; cookie stickiness is no longer used for it. Another HTTP request does not move or destroy the open WebSocket. A broken connection does need a new connection and application recovery. AWS documents this behavior.

Socket.IO can use repeated HTTP polling requests for one transport session. Those requests must reach its owning process in the documented multi-node setup. Configuring WebSocket-only transport removes that polling requirement, but also removes the polling fallback. Applications broadcasting across processes still need a suitable adapter. See the Socket.IO multi-node guide.

Adding the Redis adapter does not eliminate the affinity requirement when polling remains enabled. Its documentation explicitly describes HTTP 400 failures when a request reaches a server that does not know the session. The adapter distributes events between servers; plan transport routing separately. Redis adapter requirements.

Choose the routing key deliberately

Cookie affinity distinguishes browsers that share a public IP. It depends on the client returning the cookie, so test the client you serve. A curl command without a cookie jar is a different test from a browser that keeps cookies. For cross-origin applications, test the browser's credential and cookie rules too.

IP affinity groups traffic by the address seen by the proxy. Shared NAT can put many users on one backend, while an address change can move a user. NGINX's ip_hash uses the first three octets of an IPv4 address or the full IPv6 address. That IPv4 grouping is broader than a single host. Check which address reaches the balancer when another proxy sits in front of it. NGINX upstream documentation.

A routing cookie is also separate from the application's login cookie. Neither the backend name nor a client-supplied routing value should grant access to an account. Keep authentication and authorization in the application.

A cookie-affinity test with HAProxy

We tested the following configuration with HAProxy 3.2.25 and two disposable HTTP backends. This is a loopback HTTP lab: both backends return their own name at / and HTTP 200 at /healthz. Run them on ports 8001 and 8002 in the same network namespace as HAProxy. In a container setup, 127.0.0.1 means that container's network namespace.

defaults
    mode http
    timeout connect 1s
    timeout client 30s
    timeout server 30s
    timeout check 1s

frontend local_test
    bind 127.0.0.1:8088
    default_backend sessions

backend sessions
    balance roundrobin
    cookie SERVERID insert indirect nocache httponly
    option httpchk GET /healthz
    http-check expect status 200
    default-server inter 1s fall 2 rise 2
    server web1 127.0.0.1:8001 check cookie a
    server web2 127.0.0.1:8002 check cookie b

SERVERID is the proxy's cookie. In this lab, a selects web1 and b selects web2. indirect strips that routing cookie before forwarding to the application. nocache prevents a response that sets the persistence cookie from being cached for other clients; it is not a blanket policy for private application content. httponly adds HttpOnly. For an HTTPS deployment, add secure and choose the cookie attributes for your origin arrangement. Keep this HTTP example confined to the lab. HAProxy cookie reference.

Validate the file before starting the proxy:

haproxy -c -f haproxy.cfg

Use a fresh temporary directory for curl's cookie jar:

lab_dir=$(mktemp -d)
for n in 1 2 3 4; do
  curl -sS -c "$lab_dir/cookies.txt" -b "$lab_dir/cookies.txt" \
    http://127.0.0.1:8088/
  printf '\n'
done

The first response chooses a backend and sets SERVERID. The following responses should identify that same backend while it remains eligible. Run requests without -b and -c to compare fresh-client routing. These read-only requests check routing; they do not prove that a login or a cart survives a failure.

What happened when we removed a backend

For the state test, our toy /login endpoint set a separate APPSESSION cookie that only its own backend recognized. This deliberately models local session ownership. It is not an authentication implementation.

The table records six cases from the same lab. Health checks were allowed to reach the stated status before each routing assertion. These counts are observations, not a throughput benchmark or a promise about detection time.

Test Observed result
12 requests with no cookie jar Six reached web1; six reached web2.
Login, then 12 requests with one jar All 12 reached the login backend and found its toy session. The backend never received SERVERID.
Put the pinned backend in DRAIN Six fresh requests went elsewhere. Six requests with its cookie still reached the draining backend.
Make its health endpoint return 503; wait for DOWN The saved jar reached the other backend and received a replacement routing cookie. Its local application session was missing.
Restore the failed backend to UP The updated cookie kept the client on the replacement backend.
Put that replacement in MAINT The next request moved to the remaining backend despite the saved cookie.

DRAIN and MAINT produced different behavior with these persistence settings. A deployment procedure that waits for fresh traffic to stop can still leave sticky clients on the draining process. Give existing sessions a deadline and test what users see when that deadline expires. Our HAProxy health-check tutorial covers the separate health and maintenance controls.

Do not transfer those drain results to every balancer. AWS ALB sends new requests away from a target being deregistered, and duration-based stickiness chooses another target if the cookie points to an unhealthy or deregistered one. ALB target attributes describe those rules. In either setup, the replacement needs access to any application state the user must keep.

Test the user flow behind the healthy endpoint

A health endpoint returning 200 can coexist with lost sessions. Repeat a login and a harmless authenticated read through the public hostname, retain the client's cookies, then remove the selected backend in staging. Record the backend before and after, the response status, and whether the application still recognizes the session. For a WebSocket client, also test disconnect, reconnect and recovery of missed messages.

Watch request rates, errors and latency by backend. One busy pinned client can concentrate work on a node even when the pool's totals look normal. A shared session store introduces its own availability and expiry behavior; include its failure in the test.

Checks of a load-balanced hostname from several regions sample the public route. They do not guarantee that every backend is tested or that a logged-in session survives. Use per-backend readiness checks and a cookie-preserving application test alongside public uptime monitoring. Before the next rolling deployment, run that saved session through a backend removal and check the user's data on the replacement.

💬 Comments (0)

No comments yet — be the first to weigh in.

Join the conversation.