Interview Brush-Up · Scaling and state
Stateless vs stateful services: why your login breaks on server two
In one line: Keep no user data on the server, so any server can take any request.

Your app runs on one server and everything works. Traffic grows, so you add a second server and put a load balancer in front. Nothing in the code changed.
Then the bug reports start: “I keep getting logged out.” “My cart emptied itself.” It doesn’t happen every time, only now and then, and you can’t reproduce it on your laptop.
The code didn’t break. Your servers are stateful, and the load balancer just exposed it.
What “state” means
State is anything a server remembers about a user between requests:
- Login sessions: “this cookie belongs to user 42”
- Shopping carts kept in memory
- Uploaded files saved to the server’s local disk
- In-progress work, like a multi-step form or an export being generated
With one server, keeping this in memory is invisible. Every request lands on the same machine, so the memory is always there. With two servers, it isn’t.
Stateful vs stateless
A service is stateless when it keeps no user data between requests, so any server can handle any request.
Here’s the same login on both kinds of server:
| Stateful server | Stateless server |
|---|---|
| You log in on Server A | You log in on Server A |
| Session kept in A’s memory | Session saved in Redis (shared) |
| Next request goes to Server B | Next request goes to Server B |
| B has never seen you | B reads your session from Redis |
| “Please log in again” | Still logged in ✓ |
The load balancer is doing its job by spreading requests across servers. The stateful design assumed it wouldn’t.
The bug in code
Here’s a stateful login in FastAPI. Sessions live in a Python dictionary, which means in this server’s memory:
import uuid
from typing import Annotated
from fastapi import Cookie, FastAPI, HTTPException, Response
app = FastAPI()
sessions: dict[str, str] = {} # session_id -> user_id, in THIS process only
@app.post("/login")
def login(user_id: str, response: Response):
session_id = uuid.uuid4().hex
sessions[session_id] = user_id
response.set_cookie("session_id", session_id, httponly=True)
return {"ok": True}
@app.get("/me")
def me(session_id: Annotated[str | None, Cookie()] = None):
user_id = sessions.get(session_id)
if user_id is None:
raise HTTPException(status_code=401, detail="Please log in again")
return {"user_id": user_id}
Run two copies of this behind a load balancer and /me fails whenever it lands on the copy that didn’t handle /login. It also fails for everyone when you deploy, because restarting the process wipes the dictionary.
The stateless version keeps the exact same logic but moves the dictionary into Redis, which every server shares:
import uuid
from typing import Annotated
import redis
from fastapi import Cookie, FastAPI, HTTPException, Response
app = FastAPI()
store = redis.Redis(host="redis", port=6379, decode_responses=True)
SESSION_TTL = 60 * 60 * 24 # 24 hours
@app.post("/login")
def login(user_id: str, response: Response):
session_id = uuid.uuid4().hex
store.setex(f"session:{session_id}", SESSION_TTL, user_id)
response.set_cookie("session_id", session_id, httponly=True)
return {"ok": True}
@app.get("/me")
def me(session_id: Annotated[str | None, Cookie()] = None):
user_id = store.get(f"session:{session_id}") if session_id else None
if user_id is None:
raise HTTPException(status_code=401, detail="Please log in again")
return {"user_id": user_id}
Now any server can answer /me, and you can restart all of them without logging anyone out.
Three ways to handle sessions

1. Sticky sessions
The load balancer pins each user to one server, usually with a cookie or by hashing the client’s IP. The servers stay stateful, but each user always reaches the server that holds their session.
- ✅ No code changes and no extra infrastructure.
- ❌ When that server dies or is redeployed, its users lose their sessions.
- ❌ Load can become uneven: one server can end up with all the heavy users.
Sticky sessions are a fine stop-gap, and they’re still the right tool for long-lived connections such as WebSockets. As the main way to store sessions, they make every deploy log people out.
2. Shared session store (Redis)
Servers keep nothing in memory. The session lives in a fast shared store, usually Redis, sometimes the database. This is what the code above does.
- ✅ Any server can handle any request, and sessions survive restarts and deploys.
- ✅ Logging someone out is just deleting a key.
- ❌ One extra network lookup per request (usually well under a millisecond inside the same data centre).
- ❌ Redis is now critical: if it goes down, everyone is logged out. Run it with a replica.
This is the common default for web apps.
3. Signed token (JWT)
The server puts the session data into a token, signs it, and gives it to the client. The client sends the token with every request, and any server can verify the signature without looking anything up.
- ✅ No lookup at all, which suits many services or many regions.
- ❌ Hard to revoke. A token stays valid until it expires, even after the user logs out or is banned. Teams use short expiry times plus refresh tokens, or keep a list of revoked tokens (which brings back a lookup).
- ❌ The payload is signed, not encrypted. Anyone holding the token can decode and read it, so never put secrets in it.
Stateless doesn’t mean “no state”
This is the part people get wrong. A stateless service still has users, sessions and carts. The state hasn’t disappeared. It has moved out of the server, into Redis, a database, object storage or the token.
That’s also why some services are stateful on purpose: databases, caches, message brokers, WebSocket servers and multiplayer game servers. Holding state is their job. The goal isn’t to remove state everywhere. It’s to keep it out of the app servers you want to scale freely, and put it in a few places designed to hold it safely.
Other state to move out of the app server:
- Uploaded files → object storage (S3 or similar), not the local disk.
- Scheduled jobs → a single scheduler or a job queue. Otherwise, every server runs the same cron job and users get the same email five times.
- In-memory caches are fine, as long as losing one only makes things slower, never wrong.
Why it matters for scaling
Modern infrastructure assumes any server can disappear at any moment:
- Autoscaling adds servers at peak and removes them at night.
- Rolling deploys replace servers one by one.
- Failover sends traffic elsewhere when a server or zone dies.
- Kubernetes replaces pods whenever it needs to.
All of that is safe only if killing a server loses nothing. That’s why stateless processes are one of the rules of the well-known Twelve-Factor App guidelines.
Checklist
- ✅ App servers keep no user data in memory between requests.
- ✅ Sessions live in a shared store (Redis) or a signed token (JWT).
- ✅ Uploaded files go to object storage, not the local disk.
- ✅ Scheduled jobs run once, not once per server.
- ✅ The shared store is highly available itself.
- ✅ JWTs are short-lived and hold no secrets.
- ✅ Sticky sessions only where you need them, such as WebSockets.
If you can kill any app server at any moment and no user notices, your service is stateless.