Stateless vs Stateful Services: Why Everyone Says "Go Stateless"
Your app works perfectly on one server. Add a second behind a load balancer and users start getting logged out at random. "Make it stateless" is the standard advice, but the state doesn't disappear; it moves. Here's what stateless really means, what sticky sessions cost you, and when keeping state in the process is the right call.
It Worked on One Server
Your app runs on one server and everything is fine. Traffic grows, so you add a second server and put a load balancer in front. Within an hour, users report being logged out at random, their carts emptying between pages, and file uploads "disappearing" after they finish.
Nothing is broken in the code. It's doing exactly what it did before. The difference is that each server now keeps its own copy of things that used to have only one copy: logged-in sessions in memory, uploaded files on local disk, a cache of the current user's permissions. A request that lands on the "wrong" server can't see any of it.
That's the problem "go stateless" solves. It's worth understanding precisely, because the advice is often repeated without the important part: state doesn't go away. You move it somewhere every server can reach.
What "Stateless" Actually Means
A service where any instance can handle any request, because nothing the request needs is stored in that specific instance's memory or disk between requests. Everything comes from the request itself (a token, parameters) or from a shared store (a database, cache, or object storage).
A service where an instance holds data that later requests depend on: sessions in memory, files on local disk, open WebSocket connections, an in-process game world. Requests for a given user or key have to reach that instance.
A stateless service still uses plenty of state. It just doesn't own any. Your database is very stateful. The point is to concentrate state in a few systems designed to hold it (replicated, backed up, durable) and keep the many copies of your application code disposable.
Why Stateless Is the Default Advice
When no instance is special, a lot of operational work gets easier:
| Operation | Stateless | Stateful (state in the process) |
|---|---|---|
| Scale out | Start another instance; the load balancer uses it immediately | New instance gets only new users; existing load stays where it is |
| Scale in / autoscaling | Stop any instance | Stopping one loses its users' state, so you drain it first |
| Instance crash | Requests retry on another instance; users barely notice | Every user on that instance loses their session, cart, or connection |
| Deploys | Rolling restart with no user impact | Every restart wipes state unless you migrate it |
| Load balancing | Any algorithm works: round robin, least connections | Must route by user or key, which can leave servers unevenly loaded |
| Debugging | A request's behavior depends only on the request and shared stores | Behavior depends on which instance it hit and that instance's history |
The common thread is that stateless instances are interchangeable. That's what lets platforms like Kubernetes, ECS, or serverless runtimes move, replace, and scale them freely.
Try It: Where Does the Session Live?
Eighteen users are logged in across three servers. Send traffic, add and kill servers, and run a deploy, under three different setups.
Here's what to look for in each mode:
- In-memory sessions. Send one round of traffic. Most requests land on a server that has never seen that user, and they get logged out. This is the "random logouts" bug from the opening.
- In-memory + sticky LB. Traffic works now, because the load balancer always sends each user to the server holding their session. But kill a server and its users lose their sessions. Add a server, send traffic a few times, and look at the request counts: the new server sits nearly idle, because sticky users never move. A rolling deploy logs out everyone.
- External session store. Every operation is a non-event. Kill a server, deploy, add capacity; load spreads evenly and nobody is logged out. The cost is a network call to the store on each request, and a store you now have to keep available.
The Middle Ground: Sticky Sessions
Sticky sessions (session affinity) route each user to the same instance, usually with a load balancer cookie. They're a quick way to get an in-memory app working on several servers, and they're fine for some workloads. They come with real costs:
- Failures still lose state. Stickiness decides where requests go; it doesn't copy anything. When an instance dies, its users start over.
- Load gets uneven. Long sessions pin users to old instances, so new instances take time to pick up load. Autoscaling helps less than you'd expect.
- Deploys and scale-in need draining. You have to stop sending new users to an instance and wait for existing sessions to finish or expire before stopping it.
- Stickiness breaks quietly. Cookie stripping, clients that ignore cookies, and changes to load balancer config all send users to the wrong instance, and the result looks like a random bug.
โ Stickiness as an optimization, not a requirement
Stickiness works best when it's a performance optimization rather than a correctness requirement. Routing a user to the same instance so its local cache is warm is fine, as long as a request to any other instance still works correctly, just a bit slower.
Where the State Goes
Making a service stateless is mostly a checklist of things to move out of the process:
| State | Move it to | Notes |
|---|---|---|
| Login sessions | Redis / Memcached, a database table, or a signed token | Tokens (like JWTs) put the state in the client; revocation gets harder |
| Uploaded files | Object storage (S3, GCS, Azure Blob) | Local disk on containers is temporary anyway |
| Caches | Shared cache, or keep it local but treat it as disposable | A local cache is fine if a miss is only slower, never wrong |
| Scheduled jobs and locks | A job queue, or a lock in the database or Redis | Otherwise every instance runs the cron job |
| Rate-limit counters | Redis or the API gateway | Per-instance counters let clients exceed the limit N times over |
| WebSocket connections | Can't be moved, but subscriptions can be shared via pub/sub | Each connection is stateful by nature; see below |
For sessions specifically, you have two stateless options: keep sessions in a shared store and send only an ID in the cookie, or put the session data in a signed token the client sends with every request. The trade-offs, especially around logging users out, are covered in Sessions vs JWT.
โ ๏ธ You've moved the problem, not removed it
A shared session store is now a dependency on every request. If Redis goes down, every user is logged out at once, across all instances. Treat it like the database: replicate it, monitor it, and decide what the app does when it's unreachable.
When Stateful Is the Right Answer
Stateless is the right default for request/response web services. It isn't always right, and some systems are better because they keep state in memory:
- Real-time connections. WebSocket and SSE servers hold a long-lived connection per client. Chat, multiplayer games, and collaborative editors route each room or document to one instance so updates don't need a round trip through a shared store.
- Databases, queues, and caches themselves. Somebody has to hold the state. These systems handle it with replication, partitioning, and leader election, which is exactly the complexity you wanted to keep out of your app servers.
- Latency-critical in-memory work. Matching engines, game servers, and actor systems (Orleans, Akka) keep hot state in memory and route requests by key, because a network hop per operation is too slow.
These systems are stateful by design, and they put real engineering into routing, failover, and recovery. Being stateful by accident, because sessions happened to be in memory, gives you the downsides without any of that.
A Quick Way to Check
Ask: "If I kill any instance right now, at random, does any user notice anything beyond a retry?" If the answer is no, the service is stateless in the way that matters. If it's yes, list what they'd lose. That list is the state you need to move out, or deliberately design around.
Key Takeaways
- Stateless means interchangeable instances. Any instance can serve any request, so they can be scaled, replaced, and restarted freely.
- State doesn't disappear. It moves to databases, caches, object storage, or the client, which become critical dependencies.
- Sticky sessions get you running, not resilient. Crashes and deploys still lose state, and load spreads unevenly.
- Check sessions, uploads, caches, cron jobs, and counters. Those are the usual places state hides in an app server.
- Some systems should be stateful, like real-time connections, data stores, and in-memory engines, but by design, with routing and failover planned.
- Test it by killing an instance. If users don't notice, you're done.
References
- The Twelve-Factor App: VI. Processes, which recommends stateless, share-nothing processes
- AWS: Sticky sessions for your Application Load Balancer
- Load Balancing Strategies: Round Robin Is Just the Beginning
- Sessions vs JWT: The Auth Token Debate