Est.

HAProxy Sticky Sessions for Stateful Browser Backends

How HAProxy pins browser sessions to specific backend servers.

Correspondent · · 14 min read
Cover illustration for “HAProxy Sticky Sessions for Stateful Browser Backends”
Browser Sessions · September 21, 2026 · 14 min read · 3,050 words

HTTP forgets everything the moment a response leaves the server. That's the whole design premise, and it works fine right up until an application needs to remember who a user is between one request and the next. Session data, shopping carts, half-finished uploads: all of it typically lives on one specific machine, in memory or on local disk. A load balancer sitting in front of a pool of servers has no idea any of that exists. It just sees requests and routes them according to whatever algorithm it's running, so a session created on Server A can get answered, one request later, by Server B, a machine that has never heard of that user.

The failure chain is not subtle. A user logs in, HAProxy sends the request to Server A, and Server A writes a session into its local memory. The next click comes in, the load balancer picks Server B this time because that's what round-robin does, and Server B has no record of any login. So it either bounces the user back to a sign-in page or, worse, quietly starts a new session while the old one still holds an emptied cart or an in-progress file upload. Nobody touched the database. Nothing crashed. The infrastructure did what it was configured to do, and the user experience broke anyway.

Some of this is a genuine architecture requirement, not a workaround. WebSocket connections need to stay pinned to the same backend node because the connection is inherently long-lived and stateful. Multi-step file uploads that stream to local disk on one server run into the same wall. So do older applications built before anyone thought to externalize session storage, where there's no shared layer to fall back on. But before touching any load balancer config, ask the harder question: is this application stateful by necessity, or stateful because nobody ever built the shared session store? That second case is common, and in most of those situations the fix is a shared external store like Redis, which makes every backend interchangeable and removes the need to manage sticky sessions. Everything covered below is a tactical fix. It solves routing, not the underlying architecture, and that distinction is going to matter again before this piece is done.

What sticky sessions do inside a load balancer

Session persistence, in HAProxy's vocabulary, is a policy that ties a client's identity to one backend server for as long as that session needs to last. The identity in question might be a source IP, a cookie value, or some other token the load balancer can pull out of the request. Whatever it is, the binding has to survive multiple requests, dropped connections, and keep-alive reuse, all without asking the backend server to do anything differently.

HAProxy answers the question "what identifies this client?" three ways. Source IP address is the crudest and cheapest option. A load-balancer-issued cookie is the most precise for browser traffic. A stick table, an in-memory key-value store inside HAProxy itself, can key on an IP, a cookie, a custom header, or even a URL parameter. Each one trades off differently across three things that come up repeatedly in this piece: how precisely it identifies one client, how much overhead it adds per request, and whether it forces the load balancer to hold state. Source IP hashing costs nothing and remembers nothing. Stick tables remember everything and require additional configuration to remain consistent across a cluster under failover. Cookies sit in between: precise and light, but usable only when the client speaks HTTP and accepts cookies. Cookie-based persistence is the default choice for anything running in a browser; the other two are exceptions to reach for when cookies aren't an option. The next three sections take each mechanism in turn, roughly in order of increasing operational weight.

On a client's first request, HAProxy picks a backend using whatever balancing algorithm the config specifies, usually round-robin or least-connections. It then inserts a Set-Cookie header naming that server. Every request after that, as long as it carries the cookie, gets routed straight back to the named machine, bypassing the balancing algorithm.

The core directive looks like this in the backend block:

cookie SERVERID insert indirect nocache

Each server line then gets its own identifier, something like cookie web1. Two keywords in that directive do real work. indirect tells HAProxy to strip the cookie before the request reaches the backend, so the application server never even sees it, which keeps HAProxy's routing invisible to the app layer. nocache marks any response carrying that cookie as non-cacheable, which stops a shared proxy sitting somewhere upstream from handing a cached response meant for one server to a completely different client.

Production configs should also add httponly and secure to that same directive. httponly keeps JavaScript from reading the cookie, closing off a class of session-hijacking attempts. secure restricts the cookie to HTTPS connections only. Neither of these is optional in a security-conscious setup, and their absence during a config review should read as a red flag, not a stylistic choice.

If the backend application already sets a cookie under the same name HAProxy is told to use (SRVID gets reused by accident more often than it should), the two headers step on each other. The fix is simple: pick a cookie name the application will never touch on its own.

There's also a prefix mode for applications that already manage their own session cookie, say a session identifier on an app built with another language runtime. Instead of adding a second cookie, HAProxy can prepend the server identifier onto the existing value:

cookie JSESSIONID prefix nocache

Cookie-based persistence beats IP-based methods on one axis: precision. It tracks a browser session, not a network address, so it survives NAT boundaries, VPN reconnects, and a phone switching from one wireless network type to another mid-session, none of which an IP-based method can claim. The tradeoff is that it only works over HTTP, only with clients willing to store and return cookies, and it adds a small amount of header weight to every request.

Checking it in practice is a two-line job with curl:

curl -c cookies.txt -b cookies.txt https://example.com/

Run it a few times and check what appears inside cookies.txt. The SERVERID value should stay identical across calls. If it's flipping between servers, the persistence config isn't doing its job, full stop, and it's worth checking before blaming anything downstream.

Source IP hashing: the zero-overhead fallback and its NAT blind spot

Source IP hashing skips cookies. HAProxy hashes the client's source address and maps the result onto a backend server, so the same IP lands on the same machine every time, with no header inserted and no table to maintain. The config is about as short as HAProxy configs get:

balance source
hash-type consistent

Skip hash-type consistent and adding or removing a single server from the pool reshuffles the hash mapping for every client, not just the ones tied to the change that just happened. With consistent hashing in place, only the clients whose mapping actually depended on the removed server get remapped. That difference turns serious in autoscaling environments, where the pool size shifts constantly. Leaving out hash-type consistent there turns every scaling event into a mass remap, sending a wave of clients against cold caches on servers they've never touched before, a thundering-herd problem that autoscaling was supposed to prevent, not cause.

The bigger structural weakness is NAT. Every user behind a corporate gateway, a mobile carrier's NAT pool, or a shared proxy shows up to HAProxy as one IP address. Source IP hashing sends all of them to the same backend server, quietly wiping out load distribution for that entire address range, sometimes without anyone noticing until one server starts running hot for no obvious reason. This is the method's real weak point, and it rules it out for any deployment with a meaningful share of mobile or corporate-network traffic.

Where it earns its keep is anywhere HTTP cookies aren't in play. Source IP hashing works at the TCP and UDP level, which makes it usable for databases, streaming protocols, and game servers where there's no HTTP layer to hang a cookie on. It also carries zero memory overhead: no table inside HAProxy, nothing to replicate across a cluster, nothing to lose on a restart. That statelessness is why it fits environments where the load balancer itself needs to stay disposable and simple. Rated against the other two methods, source IP hashing is low on precision and low on overhead, and that tradeoff is the right one to accept when simplicity is the priority over exactness.

Stick tables: in-memory key-value persistence with configurable keys, expiry, and HA replication

A stick table is HAProxy's most flexible persistence tool. It is an in-memory store that maps a trackable value, such as an IP, a cookie, a custom header, or a URL parameter, to a backend assignment, with an expiry timer attached to each entry.

The key type is configurable. type ipv4 or type ipv6 gives IP-keyed persistence, carrying the same NAT blind spot as balance source but with explicit control over how long an entry lives. type string len <N> allows an arbitrary string key, a session cookie value or a custom header, useful for multi-tenant setups routing by tenant ID. type integer handles numeric keys, say a user ID pulled straight out of a URL query parameter.

A typical table declaration:

stick-table type ip size 200k expire 30m

That line allocates room for 200,000 entries keyed on a network address, each one expiring after 30 minutes without activity. For routing by something other than IP:

stick on req.hdr(X-Session-ID)

That ties persistence to a custom session header, the right move when there's no cookie to work with, or when a multi-tenant app needs to route by tenant identifier rather than by user. WebSocket connections tend to use a string-typed table with an expiry window, checking an existing mapping on the way in via stick match before falling back to fresh server selection if nothing turns up.

A stick table lives inside a single HAProxy process. Running a cluster of multiple HAProxy instances behind, say, a Layer 4 load balancer or a round-robin traffic distribution scheme means each instance keeps its own independent table. A client whose mapping was written on instance A gets reshuffled the moment a request lands on instance B, which has never seen that client before. The fix is a peers section, configured to replicate table state across every instance in the cluster, so a failover between HAProxy nodes doesn't silently break every active session.

Operationally, the tables are inspectable live, worth knowing before a 2am incident forces someone to learn it under pressure:

echo "show table app-servers" | sudo socat stdio /run/haproxy/admin.sock

Clearing a single entry, useful when one client is stuck pointing at a dead server:

echo "clear table app_servers key 203.0.113.50" | sudo socat stdio /run/haproxy/admin.sock

Or wiping the whole table:

echo "clear table app-servers" | sudo socat stdio /run/haproxy/admin.sock

Stick tables also do double duty. The same structure holding session mappings can accumulate counters for connection rate, request rate, and error rate, all inside the same process, no external dependency required. The persistence layer and the rate-limiting layer end up sharing infrastructure instead of needing two separate systems.

Sticky server failure: option redispatch and fallback behavior

Left unmitigated, HAProxy's default behavior is to keep retrying the sticky server a client is bound to, even after that server has gone down. The client just sees errors, over and over, until the cookie expires or the session times out on its own. That default is a bad one to leave in place, and it's entirely avoidable.

option redispatch fixes it directly. Once a connection to the sticky server fails after the configured number of retries, HAProxy breaks the persistence binding, picks another available server, and issues a fresh cookie pointing there instead.

The specifics differ slightly by mechanism. With cookie-based persistence, a dead server means HAProxy ignores the now-stale cookie, routes to the next available machine, and hands back a new one; whatever session state lived on the dead server is gone, but the user gets a working page instead of a wall of errors. With balance source, the client's IP simply rehashes against whatever servers remain in the pool. With stick tables, the existing entry pointing at the dead server goes stale; without option redispatch that produces client errors, but with it, a new entry gets written pointing to the replacement server.

This works only if HAProxy notices that the server died. Health check tuning handles that:

check inter 5s fall 3 rise 2

That checks the server every 5 seconds, needs 3 consecutive failures before marking it down, and 2 consecutive successes before marking it back up. Loosen those numbers and a server that died instantly can still look "healthy" to HAProxy for a stretch of time, during which every sticky client walks straight into a wall.

If there's one takeaway from this whole section, it's that option redispatch is closer to a load-bearing default than a nice-to-have. Its absence is one of the more common causes of a rolling restart turning into a full outage for anyone holding a sticky cookie, because the load balancer keeps faithfully routing users to a server that no longer exists.

Choosing between the three methods based on client type, NAT exposure, and infrastructure state

Start with the client. Browser traffic that accepts cookies should default to cookie-based persistence, full stop. It's the most precise option HAProxy offers and the one recommended across HAProxy's own documentation, and there's little reason to reach past it for standard web traffic. Non-HTTP clients (raw TCP or UDP traffic, CLI tools, anything that can't carry a Set-Cookie header) need either source IP hashing or a stick table keyed on IP instead.

Next, check for NAT. If clients sit behind a shared gateway, corporate or mobile, source IP hashing loses precision fast, since a whole office building or a whole cell tower's worth of users collapses onto one address. Cookie-based persistence or a stick table keyed on a session token avoids that problem. Mobile users deserve a specific mention here: a phone that switches from one wireless network type to another mid-session changes its IP address outright, and cookie-based persistence handles that change more gracefully than IP-based methods, which are tied to the client's network address.

Then there's whether the load balancer itself is allowed to hold state. If it has to stay stateless, source IP hashing is the only real option, since it needs no memory and no replication setup. If statefulness is acceptable, stick tables offer the most flexibility, at the cost of additional configuration to keep table state consistent across any multi-instance HAProxy deployment.

Last question: is there already a natural routing key sitting in a header or an existing application cookie? If so, stick on req.hdr(X-Session-ID) or stick on req.cook(JSESSIONID) lets HAProxy route by that value directly, and prefix mode means the application's own cookie doesn't get duplicated or overwritten along the way.

Autoscaling deployments deserve their own rule, stated flatly: balance source should never run without hash-type consistent. Skipping it means every scaling event reshuffles the entire client base at once, instead of just the fraction actually affected by the change.

Laid out side by side: balance source gives low precision, no overhead, and a stateless load balancer. Cookie insertion gives high precision and low overhead. A stick table keyed on IP gives medium precision and low overhead but drops statelessness. A stick table keyed on a session token gives close to exact precision and low overhead, and also isn't stateless. None of these is wrong on its own; each fits a different situation, which is the whole reason HAProxy ships three of them instead of picking one and calling it done.

And the caveat from the introduction holds just as true here as it did at the start: every method on this list is a mitigation, not a fix. A shared session store, a shared external store removes the need for sticky sessions altogether by making every backend server interchangeable. Building a shared session store is the target state for new applications, even while sticky sessions cover the gap for everything already running in production.

Security considerations that apply across all three mechanisms

Missing Secure and httponly flags on the HAProxy-inserted cookie appear often enough in production configs to count as a recurring misconfiguration, not a rare slip. Both flags belong in any deployment handling real user sessions, no exceptions.

The classic setup decrypts traffic at the load balancer and forwards it as plaintext to backend servers, a design that made sense back when internal networks were assumed trustworthy by default. That assumption doesn't hold under a Zero Trust posture, where trust is not assumed based on network location alone, and encrypting the backend leg too is the more defensible choice going forward.

Cookie naming collisions aren't just a functional bug, they're a security surface. If the application happens to set a cookie under the same name HAProxy uses for server selection, that cookie becomes something the application, or an attacker manipulating application behavior, could potentially overwrite or spoof. A unique cookie name the application never touches closes that gap for good.

Stick tables carry a security bonus that's easy to overlook: the same counters used for session persistence can track connection rates and request rates per IP at the same time, feeding straight into rate limiting and abuse detection, without pulling in a separate dependency. It's infrastructure already running for one job, quietly doing a second.

For applications sitting behind authenticated surfaces, the kind that draw sustained attack traffic, HAProxy Enterprise's WAF runs in the same process as the load balancer itself, adding overhead described as negligible in vendor benchmarks. Its model-based detection engine reports balanced accuracy around 98.5% in vendor testing, with the open-source benchmark citing a 99.8% true-positive rate and a 97.1% true-negative rate. Those numbers come from vendor-reported testing, a data point rather than a guarantee, but they're relevant context for anyone weighing how much security infrastructure to stack on top of a sticky-session setup protecting a login-gated application.

Sources

  1. Two ways to enable sticky sessions in HAProxy (guide)
  2. How to Implement Sticky Sessions in HAProxy for WebSockets
  3. haproxy.com
  4. Session persistence
  5. Load Balancing, Affinity, Persistence & Sticky Sessions
  6. en.wikipedia.org
  7. haproxy.com
  8. haproxy.com
Filed underBrowser Sessions

More in Browser Sessions