Stateful and stateless
Article.
“Is REST stateless?” expects the answer yes. The interesting question is the one after it: stateless costs you something, so what is it, and where do teams actually pay it?
The short answer is revocation. You cannot have stateless revocation, and every scheme that claims to is reintroducing state somewhere you have not looked.
What stateless actually means
Fielding’s constraint:
Each request from client to server must contain all of the information necessary to understand the request, and cannot take advantage of any stored context on the server.
Two halves, and one common misreading.
- Each request is self-contained — the server can serve it without knowing what came before.
- No server-side session state — “user A is mid-checkout at step 3” lives in the request or in a store the request identifies.
The misreading: statelessness is about session state, not resource state. Your database is full of state and that is fine. The constraint is that the server must not remember this client between requests.
The mechanism: statelessness is a routing property
Everything people list as a benefit is one thing wearing five hats.
If the server holds no per-client memory, any node can serve any request, so routing is free:
stateless stateful
→ [ node A ] → [ node A ] holds Alice's session
→ [ node B ] ✗ [ node B ] has never heard of Alice
→ [ node C ] ✗ [ node C ]Horizontal scaling, resilience to a node dying, rolling deploys, autoscaling and cacheability all follow from that one line. They are not five properties; they are one property observed from five angles.
And the cost follows just as directly: every request must carry its own context, so requests get larger, and anything the server would have remembered must be recomputed, re-sent, or re-fetched.
Where the state actually goes
It never disappears. It moves, and each destination has a different failure.
| Location | Failure when it goes wrong |
|---|---|
| Server memory | a deploy logs everyone out |
| Sticky session | draining a node logs out its users |
| Shared store | the store is now a hard dependency |
| The token itself | you cannot revoke it |
| The client | the client can lie |
Server memory
sessions = {} # in-process dict
@app.post("/login")
def login():
sessions[sid] = {"user_id": 42, "cart": [...]}Works perfectly on one node and fails the first time you have two. It also means every deploy signs out every user, because the process restarts and the dict goes with it. Teams discover this the week they start deploying more than weekly.
Sticky sessions
The load balancer pins a client to the node holding its session, usually by cookie or IP hash. It is the smallest change that makes the previous design survive multiple nodes, and it buys that by coupling the balancer to a node’s memory.
Three consequences that arrive later:
- Scale-in logs out a cohort. Autoscaling removes a node and takes its sessions with it. The users affected are whoever was pinned there, which is random and hard to reproduce.
- Load balances unevenly. Assignment happens once at session start, so a node that received the heavy users keeps them for hours.
- Rolling deploys become disruptive rather than invisible.
Sticky sessions are not always wrong — they are the correct answer for WebSockets, below — but reaching for them to avoid moving session state is buying a scaling problem to postpone a refactor.
A shared session store
session = await redis.get(f"sess:{sid}") # one round trip, every requestGenuinely stateless servers, with the state in Redis. Any node serves any request, deploys are invisible, scale-in is safe.
What you bought instead: Redis is now in the critical path of every authenticated request. If it is down, nobody is logged in. That is a real availability coupling and it deserves the same treatment as any other hard dependency — replication, a considered timeout, and a decision about what happens on failure.
The token itself
JWT = header . claims . signature
server verifies the signature, trusts the claimsNo lookup at all. The request carries its own proof, so the server needs nothing — which is the purest form of the constraint and the reason JWTs are popular.
The revocation problem
Here is where the trade becomes unavoidable, and it is the question interviews converge on.
A stateless token cannot be revoked, because revocation means the server knows something the token does not say — and knowing is state.
user clicks "log out everywhere"
↓
server has no record of the token, so nothing to delete
↓
the token stays valid until it expiresEvery fix reintroduces state:
- A revocation blocklist — a lookup per request, which is the shared store again with extra steps.
- Short expiry plus refresh tokens — the access token stays stateless and short-lived, the refresh token is stateful and revocable. This is the standard answer, and it is honest about being a compromise: revocation takes effect within the access token’s lifetime, not immediately.
- A token version claim checked against the user record — a database read per request, so you are stateful again but at least the state is already there.
The senior position is not “JWTs are stateless so use them”. It is: choose the window you can tolerate between a revocation and its effect, and let that pick the mechanism. If the answer is “immediately”, you need a lookup and should stop pretending otherwise.
Where it breaks in production
The token that grew. Permissions moved into the JWT to avoid a lookup, then
a user acquired sixty roles, and now the header exceeds the proxy’s limit —
often 8 KB — and requests fail for some users: nginx’s default
large_client_header_buffers 4 8k answers 400 (“Request Header Or Cookie
Too Large”), other servers 431. Put an identifier in the token, not a permission set.
“Stateless” that is not. A rate limiter counting in process memory, an in-memory cache of user preferences, an idempotency check against a local dict — each makes a node non-interchangeable while the architecture diagram still says stateless. It presents as behaviour that differs depending on which node you hit, which is miserable to debug.
The clock. Stateless auth depends on expiry, and expiry depends on time. A node with a skewed clock rejects valid tokens or accepts expired ones, and it does so for only the fraction of traffic that reaches it.
Cookies are not the same question. A cookie holding a session id is stateful — the id means nothing without the server’s store. A cookie holding a signed, self-contained token is stateless. Statelessness is about whether the server remembers, not about whether a cookie is involved.
WebSockets: the honest exception
A long-lived connection is stateful by construction. The connection is the state, it lives on one node, and no constraint changes that.
So the answer is not to make it stateless but to keep the state small and recoverable: the node holds the socket, everything else — room membership, fan out, session data — lives in Redis, and the client reconnects cleanly. See Scaling WebSocket Applications.
The trade not taken
Strict statelessness is a means, not a goal, and there is a scale below which it buys nothing.
A single application server with in-memory sessions is simpler, faster, and has no Redis to operate or JWT expiry to reason about. For an internal tool with two hundred users, that is the correct architecture, and the deploy-logs-you-out problem is genuinely not worth solving.
The constraint earns its cost when you need more than one node — for capacity, for zero-downtime deploys, or for surviving one dying. Adopting it earlier is paying an availability dependency and a revocation compromise to solve a problem you do not have.
Related
Interview angle 7
- “Is REST stateless, and what does that mean?” — each request carries everything needed to serve it, and the server keeps no per-client session state. Resource state in the database is unaffected; the constraint is about remembering this client between requests.
- “Why does it matter?” — because it makes routing free. Any node can serve
any request, and horizontal scaling, node failure, rolling deploys and
autoscaling all follow from that one property.
- Follow-up: “What does it cost?” — every request carries its own context, so requests grow and anything the server would have remembered must be re-sent or re-fetched.
- “Can you revoke a JWT?” — not while it stays stateless. Revocation means
the server knows something the token does not say, and knowing is state. Every
fix — a blocklist, a token version claim, short expiry with a revocable
refresh token — reintroduces it somewhere.
- Follow-up: “So what do you actually do?” — decide the window you can tolerate between revoking and it taking effect. Short-lived access tokens with a stateful refresh token is the standard compromise; if the answer must be “immediately”, you need a lookup per request.
- “What is wrong with sticky sessions?” — they couple the load balancer to a node’s memory. Scale-in logs out whoever was pinned to the removed node, assignment happens once so load balances unevenly, and rolling deploys become disruptive.
- “Your JWT auth started failing for some users.” — the token grew. Permissions were embedded to avoid a lookup, and the header now exceeds the proxy’s limit. Carry an identifier, not a permission set.
- “Are WebSockets stateless?” — no, and they cannot be; the connection is the state and it lives on one node. Keep that state minimal and put room membership and fan-out in Redis so a reconnect elsewhere works.
- “When would you not bother?” — below the point where you need a second node. For an internal tool, in-memory sessions are simpler and the deploy-logs-you-out problem is not worth an availability dependency.