Session persistence binds users to specific backends for their session duration. Configuring it properly requires careful consideration of costs and testing methods.
Setting the persistence timeout aligns with the application rather than a round number. Testing the failure case ensures the configuration is robust before deployment.
A user logs in, adds three items to their basket, clicks checkout — and finds themselves logged out with an empty basket. Nothing crashed. No errors were logged. Every health check is green. The load balancer simply did its job too well, spreading that user’s requests across backends that do not share session state.
Session persistence — sticky sessions — binds each client to one backend for the lifetime of their session. It is simple in concept, takes about ten minutes to switch on, and is quietly easy to get wrong in ways that only show up under load, during a deployment, or on the one afternoon a backend fails.
This is how to configure it properly on Tula, what it costs you, and how to prove it works before your users do the testing on your behalf.
Start by asking whether you need it at all
Persistence is a workaround. It is worth being honest about that up front, because the answer changes what you should build.
Applications fall into three groups.
Stateless applications hold nothing between requests. Every request carries whatever the server needs — a signed token, an API key, a full set of parameters. Any backend can serve any request. These need no persistence, and they are the easiest thing in the world to scale.
Applications with shared session state keep sessions somewhere every backend can reach: Redis, Memcached, a database table, an encrypted cookie carrying the session itself. Again, any backend can serve any request. Persistence buys nothing except a slightly warmer local cache.
Applications with local session state keep the session on whichever server created it — in process memory, in a file on local disk, in a per-process cache. Only that one server can continue the conversation. This is the group that needs persistence, and it is the group where persistence is a plaster over an architectural constraint rather than a feature.
If you are in the third group and can move to the second, do that instead. Shared session storage removes an entire category of problem: your pool balances evenly, a backend failure costs users nothing, deployments stop being delicate, and you can add capacity without waiting for old sessions to drain. Persistence solves the symptom. Shared state solves the cause.
That said, plenty of real applications cannot be rearchitected this quarter, and some never will be. For those, persistence configured properly is far better than persistence configured by guesswork.
What persistence costs you
Switching it on is not free, and the costs are worth understanding before you pick a mode.
Your pool stops balancing evenly. The load balancer can no longer send each request to the least-loaded backend, because it is obliged to honour an existing binding. Distribution drifts from “even” towards “however the bindings happened to fall”. With a large number of similar clients this averages out. With a small number of heavy ones, it does not.
New capacity fills slowly. Add a fifth backend to a pool of four and, without persistence, it takes its share of traffic immediately. With persistence, it only receives clients whose bindings have not yet been established or have expired. If your persistence timeout is two hours, a new backend can sit nearly idle for most of an afternoon while the original four stay busy — which is precisely the wrong behaviour, because you added it during a traffic peak.
Draining becomes a waiting game. Taking a backend out of service for maintenance means either waiting for its bindings to expire, or breaking sessions. The wait is bounded by your persistence timeout, which gives that number a second job it did not have before.
You need more headroom. Because distribution is less even, the busiest backend runs hotter than the average. Sizing a pool on average load is optimistic; size it on the busiest member.
A worked example makes the last point concrete. Suppose four backends, 10,000 concurrent sessions, and persistence by source IP. If your traffic is broadly residential and evenly spread, each backend carries around 2,500 sessions and life is good. Now suppose 30% of your users reach you through three large mobile carriers, all behind carrier-grade NAT. Those 3,000 sessions hash to at most three source addresses, so they land on at most three backends — and quite possibly on one. That backend is now carrying up to 3,000 NAT-borne sessions on top of its ~1,750 share of the rest: getting on for three times the load of the quietest member of the pool, with every health check still green and every dashboard still showing “4 backends up”.
That is not a hypothetical failure mode. It is the single most common way source IP persistence disappoints people, and Tula’s own documentation names it: Source IP Hash “may produce uneven distribution when clients connect through shared NAT gateways”.
Option one: source IP affinity, at Layer 4
For Layer 4 VIPs, persistence comes from the Source IP Hash algorithm — one of six Tula offers, alongside Round Robin, Weighted Round Robin, Least Connections, Weighted Least Connections and URI Hash. It computes a hash of the client’s source IP address and uses it to pick a backend consistently, so connections from the same address always land on the same server. You configure it by selecting Source IP Hash as the VIP’s scheduling algorithm; there is nothing else to set up.
Its strengths are simplicity and protocol independence. It works for anything over TCP or UDP, needs no cooperation from the application, and adds no data to the traffic. If you are balancing a database protocol, a game server, RADIUS, or anything else that is not HTTP, this is the only persistence you are going to get.
Its weaknesses all follow from using an IP address as a proxy for identity.
Shared NAT skews distribution, as above. Every user behind a corporate gateway or a mobile carrier presents the same source address.
Changing IPs break affinity. A phone moving from Wi-Fi to mobile data changes source address mid-session, hashes to a different backend, and loses its session. To the user this looks like being randomly logged out while walking out of a building — a support ticket that is almost impossible to reproduce on a desk.
The binding is only stable while the pool is. This one catches people out, and it is worth stating precisely: the documentation notes that Source IP Hash maintains persistence “as long as the set of healthy backends remains stable”. Because the backend is chosen by hashing across the available members, changing how many members there are changes the answer for many clients, not just the ones on the backend that went away. Lose one backend out of four and you do not simply move that quarter of your users — you reshuffle a substantial share of everyone. A single failure becomes a broad session-loss event.
Source IP affinity, then, is the right tool when you are balancing non-HTTP protocols, or when you genuinely cannot touch the HTTP layer. For web traffic, there is a better option.
Option two: cookie-based persistence, at Layer 7
On Layer 7 HTTP and HTTPS VIPs, Tula can track the backend assignment with an HTTP cookie. This identifies the client rather than the client’s current network address, which fixes both of the big source-IP problems at once: routing stays consistent when a client’s IP changes, and users behind shared NAT are distributed normally because each browser carries its own cookie.
Tula supports two modes.
Cookie insert
The load balancer injects its own persistence cookie into the HTTP response. The client returns it on subsequent requests and the load balancer routes accordingly. The application never sees the cookie, never has to set it, and needs no changes whatsoever. This is the right default for most deployments.
Three settings matter:
- Cookie name — for example
SERVERID. Pick something that will not collide with an application cookie. If your framework namespaces its cookies, borrow the convention. - Secure flag — set it, so the cookie only travels over HTTPS.
- HttpOnly flag — set it, so client-side JavaScript cannot read it.
There is rarely a good reason to leave those two flags off on an HTTPS service. The persistence cookie is not a session token and does not authenticate anyone, so leaking it does not hand over an account — but it does disclose which backend a user is pinned to, and it is one more piece of state that scripts on the page have no business reading. Set them and stop thinking about it.
Cookie prefix
Instead of creating a new cookie, the load balancer prepends a server identifier to an existing application cookie on the response path, and strips it again on the request path. The application sees its own cookie with its original value, unmodified. This is useful where injecting an additional cookie is undesirable — a strict cookie policy, a consent regime that enumerates every cookie by name, a client-side script that iterates over cookies and chokes on unfamiliar ones.
It carries a requirement that is easy to miss: the application must set the expected cookie on every response. If any code path returns a response without it — an error page, a redirect, a cached 304, an asset served by a different handler — there is nothing to prefix, and persistence silently fails for those requests. The user bounces to another backend and loses their session, intermittently, in a way that correlates with nothing obvious.
Use cookie insert unless you have a specific reason not to, and if you use prefix, verify the cookie really is set on every response rather than merely on the ones you tested.
Choosing between them
| Situation | Use |
|---|---|
| HTTP or HTTPS application, local session state | Cookie insert |
| HTTP application under a cookie policy that forbids extra cookies | Cookie prefix, having checked every response path sets the cookie |
| Non-HTTP protocol — database, RADIUS, game server, custom TCP | Source IP Hash |
| HTTP but you cannot terminate TLS at the load balancer | Source IP Hash, accepting the NAT and rehash caveats |
| Shared session store already in place | None — leave the pool free to balance |
| Public web traffic with significant mobile or corporate users | Cookie insert, not Source IP Hash |
Getting the timeout right
Persistence entries expire after a configurable timeout: how long the load balancer remembers a client-to-backend binding after that client’s last request. Both extremes hurt, in different and equally annoying ways.
Too short, and a user who pauses to make a coffee comes back reassigned to a different backend, mid-session — exactly the failure persistence was meant to prevent. Worse, it will not reproduce on demand, because it depends on how long someone was away.
Too long, and the costs in the earlier section bite harder: your pool drifts further out of balance, newly added backends stay idle longer, and every maintenance window inherits a longer wait before a backend is quiet enough to touch.
Thirty minutes is a sensible default for typical web applications. The number to align it with is your application’s own session lifetime — there is no value in the load balancer remembering a binding for two hours if the application invalidates the session after twenty minutes. A banking application that logs users out after ten minutes of inactivity should not carry two-hour persistence; all that achieves is holding a binding for a session that no longer exists.
Work it out from the application, not from the load balancer:
- Find your application’s idle session timeout.
- Set persistence to match it, or slightly exceed it.
- Check the result against your maintenance expectations — if that number means waiting 30 minutes to drain a backend, decide now whether you can live with that, because you will be living with it at an inconvenient moment.
What happens when a backend fails
This is the part worth rehearsing before it happens.
Persistence must never outrank health. A binding to a backend that is failing its health checks is worthless — the correct behaviour is to send that client somewhere healthy, immediately, and that is what a properly configured VIP does. If you find yourself reasoning about how to keep a client pinned to an unhealthy backend, something has gone wrong upstream in the design.
But being sent somewhere healthy is not the same as being fine. The new backend does not have the session, because the whole reason you enabled persistence is that sessions live locally. So the user is logged out, or loses their basket, or restarts the form wizard. Persistence keeps a session alive only for as long as its backend is alive; it converts “sessions are spread randomly and break constantly” into “sessions break when a backend dies”. That is a real improvement, and it is not the same as reliability.
With source IP persistence, the blast radius is larger than the failure, for the rehash reason above: losing one backend disturbs bindings across the pool, not only those belonging to the failed member.
With cookie persistence, the blast radius is limited to the clients actually pinned to the failed backend. Everyone else keeps their cookie, and their cookie still names a backend that is up. This is a further, less-discussed argument for cookies over source IP on web traffic.
If sessions genuinely must survive a backend failure, persistence is not the mechanism. Shared session storage is. Persistence and shared state are complements, not alternatives: shared state keeps the session safe, and persistence keeps the user’s requests landing on a warm local cache.
Testing it — before your users do
Persistence failures are invisible until state goes missing, so verify deliberately rather than assuming. The whole procedure takes a few minutes.
1. Confirm the cookie is issued. Make a first request and look at the response headers. You should see the persistence cookie, with the flags you configured:
curl -sk https://vip.example.com/ -c cookies.txt -o /dev/null -D - | grep -i set-cookie
Check the output actually contains Secure and HttpOnly. It is very easy to configure those, never look, and be wrong.
2. Confirm stickiness. Replay with the stored cookie and confirm every response comes from the same backend. If your application does not expose which backend served a request, add a header for the duration of the test — it is worth the five minutes:
for i in $(seq 1 10); do
curl -sk https://vip.example.com/ -b cookies.txt -o /dev/null -w '%{http_code} '
done; echo
3. Confirm new clients spread out. Discard the cookie jar and repeat. Fresh clients should not all land on the same backend — if they do, persistence is not your problem, your algorithm is:
for i in $(seq 1 10); do
curl -sk https://vip.example.com/ -o /dev/null -c /dev/null
done
4. Watch the distribution. The Monitoring > Statistics view in NetMan shows per-backend traffic. Persistence should shape the distribution, not collapse it onto one server. If one backend is carrying most of the traffic, re-read the NAT section above.
5. Kill a backend. This is the step everyone skips and the one that matters most. Stop one backend and confirm its pinned clients are reassigned to a healthy member on their next request, promptly. Then confirm what your application does to those users — because that, not the load balancer’s behaviour, is what your customers will describe when they ring up.
6. Test the timeout. Establish a session, wait slightly longer than your configured timeout, and make another request. You should be reassigned. If you are not, the timeout is not what you think it is.
Symptoms and causes
| Symptom | Likely cause |
|---|---|
| Users logged out at random, no pattern | No persistence, or persistence not applied to the VIP handling that traffic |
| Users logged out after a pause, consistently | Persistence timeout shorter than the application’s session lifetime |
| Mobile users logged out when leaving a building | Source IP persistence and a Wi-Fi–to-cellular handover |
| One backend far busier than the rest | Source IP persistence with clients behind shared NAT |
| Intermittent session loss on some pages only | Cookie prefix, and a response path that does not set the application cookie |
| Widespread session loss after a single backend failed | Source IP persistence rehashing across the changed pool |
| New backend stays idle after being added | Persistence timeout long relative to session length — expected, be patient or shorten it |
Common pitfalls
- Using Source IP Hash for public web traffic. Carrier-grade NAT will concentrate a large share of your users on one backend, and a single failure will disturb everyone. Use cookies at Layer 7.
- Cookie prefix without a guaranteed cookie. If any response path skips setting the application cookie, those requests bounce between backends.
- Leaving Secure and HttpOnly off. Free to set, mildly embarrassing to explain in an audit.
- A persistence timeout picked without reference to the application. The load balancer’s memory should match the application’s, not a round number someone liked.
- Treating persistence as a substitute for shared state. Sticky sessions keep a session alive only while its backend is alive. For anything genuinely important, persistence complements shared session storage — it does not replace it.
- Never testing the failure case. Persistence that works in the happy path and has never been tested against a dead backend is persistence you do not yet understand.
In short
Session persistence takes ten minutes to switch on and rewards a further thirty minutes of thought about mode, flags, timeout and failure behaviour. Pick cookie insert for web traffic and Source IP Hash only when you have no HTTP layer to work with; set the flags; align the timeout with the application rather than a round number; and test the backend-failure case deliberately, because that is the one your users will otherwise test for you.
And if you can move session state into somewhere shared, do that as well. Persistence and shared storage together give you a pool that balances, a failure that costs nobody their basket, and deployments that stop being an event.
The full configuration reference is in the session persistence documentation, with the scheduling options covered under load balancing algorithms and the routing features that pair with cookie persistence in the Layer 7 load balancing guide.