
You’ve just deployed a new version of your chat application. Rolling deploy — ten app servers updating one at a time, zero downtime. Or so you thought.
Within minutes, reports flood in. Users are getting mysteriously logged out mid-conversation. Others see their messages appear, then vanish. One engineer spots the pattern: every complaint timestamps to the rollout window. The issues stop the moment the deploy finishes.
The cause? Your server was holding each user’s “active in room X with these participants” state in local memory. During the rolling update, the load balancer routed some users to a fresh server instance that had no idea who they were. That server had never seen their session. From its perspective, they weren’t logged in.
This isn’t a deployment bug. It’s an architectural decision made weeks earlier — the kind you make without realizing you’re making it. You built a stateful server when you needed a stateless one, and a rolling deploy exposed it. Understanding the client-server model well enough to make that choice intentionally is what this article is about.
What We’ll Cover
- The request/response cycle in depth
- Stateless vs stateful: the most important distinction in web architecture
- Why HTTP’s statelessness is a feature, not a bug
- Sessions, cookies, and tokens: papering over statelessness
- Peer-to-peer: when the server isn’t the center
- Thin client vs thick client: where does the logic live?
- How this model breaks at scale
- Trade-offs & when this matters
- Real-world scenario: scaling a chat app
- Common mistakes engineers make
- How this shows up in interviews
- Key takeaways
The Request/Response Cycle in Depth
The client-server model is built on one asymmetry: the client always initiates, the server always waits.
This sounds obvious, but it has consequences that ripple through every design decision you make:
- The server can’t push data to a client that hasn’t asked (without additional protocols — more on this later)
- The client controls the timing and frequency of communication
- The server’s job is to be ready, respond correctly, and do nothing else
A single HTTP exchange looks like this:
sequenceDiagram
participant C as Client (Browser / Mobile App)
participant S as Server (chat.example.com)
C->>S: POST /api/messages HTTP/1.1\nHost: chat.example.com\nAuthorization: Bearer eyJhbGc...\nContent-Type: application/json\n\n{"room": "general", "text": "hello"}
Note over S: Authenticate token\nValidate payload\nPersist message\nBroadcast to room
S->>C: HTTP/1.1 201 Created\nContent-Type: application/json\n\n{"id": "msg_8f3k", "timestamp": 1716000000}
Every field in those headers is there for a reason:
Authorization: Bearer ...— the client proves who it is on every single request. The server doesn’t remember from the last one.Content-Type: application/json— tells the server how to parse the body. Both sides must agree on the format.HTTP/1.1 201 Created— the server’s verdict. Status codes are the server’s standardized vocabulary for “here’s what happened.”
🔑 Key insight: Notice the server has to do four things in that
Noteblock — authenticate, validate, persist, and broadcast — before it can respond. Every one of those steps can fail independently, and the client has no way to know which one failed unless the server tells it explicitly in the response.
Stateless vs Stateful
This is the most consequential design decision in web architecture, and most engineers stumble into it rather than choosing deliberately.
Stateful means the server retains information about each client between requests. The server “remembers” you.
Stateless means each request carries everything the server needs to fulfill it. The server has zero memory of previous requests from the same client.
Stateful Server
alice → room:general, last_seen:now
bob → room:general, last_seen:now
Alice’s next request hits Server B → unknown user → session lost
Stateless Server
Alice’s token is self-contained → any server can validate it → no session lost
Here’s the same distinction in code. A stateful server stores session data in memory:
# Stateful — session lives on this specific server process
sessions = {} # in-memory, dies on restart
def handle_message(request):
user_id = sessions.get(request.session_id)
if not user_id:
return 401 # only works if THIS server handled the login
return send_message(user_id, request.body)
A stateless server verifies a self-contained token on every request:
# Stateless — no server-side session storage
def handle_message(request):
try:
payload = jwt.verify(request.headers["Authorization"], SECRET_KEY)
user_id = payload["user_id"]
except jwt.InvalidTokenError:
return 401 # works on ANY server that knows the secret
return send_message(user_id, request.body)
The difference is subtle in development. It’s catastrophic in production the moment you add a second server.
Why HTTP’s Statelessness Is a Feature, Not a Bug
Engineers new to web development often treat HTTP’s statelessness as a limitation to work around. Senior engineers treat it as a superpower to design for.
RFC 2616, the HTTP/1.1 specification, says explicitly: “each request from client to server must contain all of the information necessary to understand the request.” This was a deliberate choice, not an oversight.
Why it was designed this way:
HTTP was designed for a distributed, unreliable network. If the server had to track client state to function, then every server failure would terminate every active user session. The protocol’s designers wanted a model resilient enough that any server could handle any request — independently and without coordination.
What it enables:
- Horizontal scaling: add servers freely — no user is “bound” to a specific one
- Fault tolerance: a server crash loses no user state (because it held none)
- Caching: a response to a stateless request can be safely cached and served by a CDN or proxy
- Debuggability: you can reproduce any bug by replaying the request, without needing to reconstruct session state
💡 Pro tip: If you can replay a single HTTP request in Postman or
curland reproduce a bug, you’re working with a stateless endpoint. If you have to log in first, navigate to a specific page, and trigger an action in a particular order — that’s stateful complexity leaking into your protocol, and it will eventually bite you in production.
Sessions, Cookies, and Tokens
HTTP is stateless, but users need to stay “logged in.” These two facts seem contradictory. The resolution is: the client carries the state, not the server.
There are three main mechanisms, each with different tradeoffs:
Cookies and Server-Side Sessions
The classic approach. On login, the server creates a session record in a database or in-memory store, assigns it a random ID, and sends that ID to the client in a Set-Cookie header. On every subsequent request, the browser automatically sends the cookie back.
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Session Store (Redis)
C->>S: POST /login\n{email, password}
S->>DB: CREATE session {user_id: 42, expires: +24h}
DB->>S: session_id = "abc123"
S->>C: 200 OK\nSet-Cookie: session_id=abc123; HttpOnly; Secure
Note over C,S: Later requests...
C->>S: GET /api/profile\nCookie: session_id=abc123
S->>DB: GET session "abc123"
DB->>S: {user_id: 42, expires: valid}
S->>C: 200 OK\n{user profile data}
The catch: the server now has state — in the session store. It’s just moved from server memory to a database. If you use server-local memory for sessions (like the default in many frameworks), you’re back to the stateful problem. The session store must be external and shared (Redis is the standard choice).
JWT (JSON Web Tokens)
JWTs encode the user’s identity and claims inside the token itself, signed with a secret key. The server verifies the signature on every request but never looks up anything in a database.
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "user_id": 42, "role": "admin", "exp": 1716086400 }
Signature: HMAC-SHA256(base64(header) + "." + base64(payload), SECRET_KEY)
// Server validates a JWT without any database lookup
function authenticate(req: Request): User {
const token = req.headers.authorization?.replace('Bearer ', '');
const payload = jwt.verify(token, process.env.JWT_SECRET);
return { id: payload.user_id, role: payload.role };
// No database query. Any server with the secret can verify this.
}
The catch: you can’t invalidate a JWT before it expires. If a user logs out or you revoke their access, the token stays valid until expiry. This is why JWTs should have short expiry times (15–60 minutes) combined with a long-lived refresh token stored server-side.
Comparison
| Mechanism | Server storage | Can be invalidated instantly | Scales horizontally |
|---|---|---|---|
| In-memory session | Server RAM | Yes | ❌ No — tied to one server |
| Redis session | External store | Yes | ✅ Yes — shared store |
| JWT | None | ❌ Not without a blocklist | ✅ Yes — stateless |
| JWT + refresh token | Refresh token only | Partially | ✅ Yes |
⚠️ Warning: Never store sensitive data in a JWT payload. The payload is base64-encoded, not encrypted — anyone can decode it. The signature only proves it wasn’t tampered with. If you need to store sensitive claims, use JWE (JSON Web Encryption) or keep them in a server-side session.

Peer-to-Peer: When the Server Isn’t the Center
The client-server model assumes a central authority. Peer-to-peer (P2P) removes it — every node is simultaneously a client and a server.
graph TD
subgraph Client-Server
C1[Client A] --> SRV[Server]
C2[Client B] --> SRV
C3[Client C] --> SRV
end
subgraph Peer-to-Peer
P1[Peer A] <--> P2[Peer B]
P2 <--> P3[Peer C]
P3 <--> P1
end
P2P makes sense when:
- The data is already distributed — BitTorrent: each downloader also uploads to others. No central server needs to hold the full file.
- Latency between peers matters more than latency to a server — WebRTC video calls route audio/video directly between browsers, skipping a server relay entirely. This cuts latency by 50–200ms vs server-proxied calls.
- Censorship resistance is a requirement — no central point to shut down.
P2P falls apart when:
- You need consistent state — who’s the authority on “the current version”?
- You need access control — anyone can connect to any peer.
- You need discoverability — how do peers find each other without a central directory? (Most P2P systems solve this with a small centralized tracker — which is technically a hybrid model.)
For most production web applications, the client-server model wins. P2P is genuinely useful for file distribution (BitTorrent), real-time media (WebRTC), and blockchain systems — and almost nowhere else.
Thin Client vs Thick Client
“Where does the logic live?” is a question that determines your app’s architecture, performance characteristics, and development complexity.
Thin client: the server does most of the work. The client renders what it receives.
Classic server-side rendered apps (PHP, Rails with ERB, Django templates) are thin clients. The server fetches data, runs business logic, generates HTML, and sends complete pages. The browser just displays them.
Thick client: the client does most of the work. The server is primarily a data API.
Modern Single Page Applications (SPAs) and mobile apps are thick clients. React/Vue/Angular apps fetch raw data from APIs and run all the rendering and business logic in the browser. The server’s job shrinks to authentication and data persistence.
Thin Client (SSR)
Fast first load · SEO-friendly · Server bears computation cost
Thick Client (SPA)
Instant interactions after load · Flexible UX · Client bears computation cost
In practice, the line has blurred. Server-Side Rendering frameworks like Next.js and Astro do both: SSR for the initial page load (fast, SEO-friendly), then hydrate into a thick client for subsequent interactions. This is the architecture most production teams land on.
💡 Pro tip: The thick client trend has moved significant compute to user devices, which is great for server costs but creates problems: business logic duplicated between client and server, JavaScript bundle sizes ballooning, and security rules that need to be enforced server-side anyway (never trust client-side validation alone).
How This Model Breaks at Scale
The client-server model works beautifully up to a point. Here’s where it starts to crack:
Problem 1: The server can’t push to clients.
HTTP’s request/response model means the server can only respond — it can’t initiate. For a chat app, this means clients have to constantly poll: “Any new messages?” every second. At 10,000 users polling every second, that’s 10,000 requests per second of pure overhead, most of which return empty.
The solution is WebSockets — a protocol upgrade that turns an HTTP connection into a persistent, bidirectional channel. After the upgrade, the server can push messages to clients without being asked. We’ll cover this deeply in Article 26.
Problem 2: Stateful servers don’t scale horizontally.
You already saw this in the opening scenario. As soon as you add a second server, any session state stored on Server A is invisible to Server B. The solutions:
- Sticky sessions (the wrong answer): configure your load balancer to always route a given user to the same server. You get the scaling, but you lose fault tolerance — if that server dies, the user’s session is gone.
- External session store (the right answer): move all session state out of the server and into Redis or a database. Now every server is identical and stateless — any of them can handle any request.
Problem 3: One server has a ceiling.
A single server can only handle so many concurrent connections before it saturates. The traditional HTTP model — one thread per request — hits a hard limit at a few thousand concurrent connections (the infamous C10k problem). Modern frameworks use async event loops (Node.js, Go’s goroutines, Python’s asyncio) to handle tens of thousands of concurrent connections on a single machine. But there’s still a ceiling, and for real-time systems like chat, that ceiling arrives fast.
🔑 Key insight: The architectural answer to all three of these problems is the same: remove state from the server tier. Stateless app servers can scale freely. Push real-time events through a message broker (Redis Pub/Sub, Kafka) rather than server memory. Store session data externally. Once your servers are truly stateless, scaling becomes a matter of adding more of them — which is the topic of Article 3.
Trade-offs & When This Matters
✅ Design for statelessness when:
- You expect to run more than one server instance (i.e., always)
- You need zero-downtime deploys without session disruption
- You want to add auto-scaling (cloud load balancers can’t route intelligently around stateful servers)
- You want to use a CDN or reverse proxy cache in front of your API
❌ Stateful design can be acceptable when:
- You’re building a single-instance internal tool that will never scale beyond one server
- You’re prototyping and correctness matters more than architecture
- You’re building a game server where a single long-lived connection to one server is the intended model (but even then, handle disconnects carefully)
⚖️ The real trade-off
Most teams don’t choose stateful — they drift into it. The default behavior of many frameworks (Flask sessions, in-memory Express session stores, PHP’s $_SESSION) is server-local session storage. It works fine in development where you have one server, and it silently breaks the moment you add a second one.
The cost of fixing this later is high: you have to audit every place state is stored server-side, migrate to an external store, and redeploy everything. The cost of doing it right from the start is low: configure Redis as your session store before you launch. The asymmetry here strongly favors designing for statelessness on day one, even if you don’t plan to scale yet.
Real-World Scenario: Scaling a Chat App
You’re building Chatflow — a real-time team messaging app, think Slack at early-stage scale. Let’s trace how the client-server architecture evolves as you grow.
Stage 1: 0–1,000 users (one server)
A single Node.js server handles everything: HTTP requests, WebSocket connections, and an in-memory Map of { roomId → Set<connectedSockets> }. When someone sends a message, you look up all sockets in that room and push it to each one. Simple, fast, works perfectly.
Stage 2: 1,000–10,000 users (you add a second server)
User complaints start immediately. Alice is connected to Server 1 via WebSocket. Bob is connected to Server 2. When Alice sends a message, Server 1 looks up its in-memory room Map — Bob isn’t there (he’s on Server 2). Bob never sees Alice’s message.
Your in-memory room map is stateful. You’ve hit the wall.
The fix: Redis Pub/Sub as a message broker
import { createClient } from 'redis';
const publisher = createClient();
const subscriber = createClient();
// When Alice sends a message on Server 1:
async function handleMessage(roomId: string, message: Message) {
await publisher.publish(`room:${roomId}`, JSON.stringify(message));
}
// Every server subscribes to all room channels:
await subscriber.subscribe('room:*', (message, channel) => {
const roomId = channel.replace('room:', '');
// Deliver to all WebSocket clients connected to THIS server in this room
localRoomMap.get(roomId)?.forEach(socket => socket.send(message));
});
Now Server 1 publishes Alice’s message to Redis. Redis broadcasts it to all subscribers — including Server 2. Server 2 delivers it to Bob. The servers are stateless with respect to message routing.
At 50,000 concurrent users: a single Redis instance handles ~100,000 pub/sub operations per second comfortably. Your app servers are fully stateless and horizontally scalable behind a load balancer. You’ve decoupled connection management (app servers) from message routing (Redis) — two concerns that scale at different rates.

Common Mistakes Engineers Make
1. Using server-local session storage in production
The most common version of the stateful trap. Express’s default express-session uses an in-memory store. Django’s default session backend is the database, which is correct, but many tutorials show SESSION_ENGINE = 'django.contrib.sessions.backends.cache' without specifying a shared cache — defaulting to local memory.
Always configure an external session store (Redis, Memcached, or your database) before deploying anything that will run as more than one process.
2. Putting too much data in JWTs
JWTs are sent on every request. A JWT payload that includes user preferences, feature flags, full permissions lists, and profile data can balloon to 4–8KB. Most browsers cap cookie size at 4KB and HTTP header size at 8KB. Hitting those limits causes silent failures that are extremely hard to debug.
Keep JWTs lean: user_id, role, exp, and nothing else. Fetch the rest from your database or cache when needed.
3. Treating thick-client validation as sufficient
If you validate a form field only in React, a user can bypass it in 30 seconds using the browser console or by crafting a raw HTTP request. Business rules — price calculations, permission checks, rate limits — must be enforced on the server. Always. The client-side version is for UX; the server-side version is for correctness.
4. Building WebSocket servers without handling reconnection
WebSocket connections drop. Mobile users switch between WiFi and cellular. Servers restart. If your client doesn’t implement exponential-backoff reconnection with state recovery (re-fetching missed messages on reconnect), users will experience silent message loss that’s nearly impossible to reproduce in development.
5. Not designing for idempotency in the request/response model
HTTP is fire-and-forget from the client’s perspective. If a POST /api/messages request times out, the client doesn’t know if the server received it or not. If the client retries, you get duplicate messages. The fix is an idempotency key: the client generates a unique ID per request attempt, and the server deduplicates on it. This is standard practice for payment APIs (Stripe does this) and should be for any mutation that has side effects.
How This Shows Up in Interviews
Interviewers use “design a chat system” specifically because it exposes whether you understand the stateless/stateful distinction. Most candidates immediately say “WebSockets” — which is correct, but it’s not the interesting part. The interesting part is what happens when you need more than one server.
The answer that stands out is: “App servers are stateless — they hold no room membership data. WebSocket connections are just a transport. All message routing goes through a pub/sub layer like Redis. This means any server can accept any client connection, and horizontal scaling is just adding more app servers behind the load balancer.”
Follow-up questions you should be ready for:
- “What happens if the Redis pub/sub broker goes down?” (Messages are lost during the outage. You’d need a message queue with persistence like Kafka for guaranteed delivery — trade-off: more complexity, higher latency.)
- “How do you handle users who are offline when a message is sent?” (Separate concern from pub/sub: store undelivered messages in a database, deliver on reconnect. This is the inbox/push notification pattern.)
- “How would you implement ‘user is typing’ indicators?” (Ephemeral state — don’t persist to DB. Broadcast a
typingevent via pub/sub with a short TTL, client shows the indicator for 2–3 seconds.)
Key Takeaways
- Statelessness is what makes horizontal scaling possible. If any server can handle any request, you can add servers freely. If requests are tied to specific servers, you can’t.
- HTTP’s stateless design is intentional and powerful — every mechanism for “staying logged in” (cookies, sessions, JWTs) is a layer on top of statelessness, not a violation of it.
- JWTs push state to the client; server-side sessions push state to a shared store. Both are correct — the choice depends on whether you need instant revocation.
- The client-server model breaks for real-time because servers can’t push. WebSockets solve the push problem, but they introduce stateful connections — which must be managed through a shared broker, not server memory.
- Thick clients mean business logic runs on user devices — which you don’t control. Server-side validation is never optional, regardless of what the client already validated.
What to Learn Next
Now that you understand the client-server model and why statelessness matters, the next steps follow naturally:
- Vertical vs Horizontal Scaling — Article 3 takes the statelessness principle and shows exactly how it enables (and constrains) scaling strategies. You need to understand this article to understand that one.
- WebSockets & Real-Time Communication — Article 26 covers the full story on how to escape HTTP’s request/response model for push-based communication — and how to do it without creating stateful servers.
- Session Management — Article 49 goes deep on everything we touched here: cookie security flags, JWT refresh token rotation, sliding sessions, and how to implement revocation properly without sacrificing performance.
Topics covered