Foundations & Mental Models Beginner → Intermediate 15 min read #02

Client-Server Architecture

A deep-dive into the request/response model that powers the web — covering stateless vs stateful design, how sessions and tokens paper over HTTP's statelessness, and why these choices directly determine whether your app can scale horizontally. Built around a real-time chat application to make the tradeoffs concrete.

Client-server model: three client devices (laptop, tablet, mobile) sending amber request arrows to a central server cluster, which returns white response arrows to each device

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

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 Note block — 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

Server A memory:
alice → room:general, last_seen:now
Server B memory:
bob → room:general, last_seen:now

Alice’s next request hits Server B → unknown user → session lost

Stateless Server

Server A memory: (none)
Server B memory: (none)

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 curl and 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

MechanismServer storageCan be invalidated instantlyScales horizontally
In-memory sessionServer RAMYes❌ No — tied to one server
Redis sessionExternal storeYes✅ Yes — shared store
JWTNone❌ Not without a blocklist✅ Yes — stateless
JWT + refresh tokenRefresh token onlyPartially✅ 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.

Three authentication mechanisms side by side: in-memory session (single server with memory chip), shared Redis session store (two servers connected to Redis), and JWT (two servers validating tokens directly from client)
Three auth mechanisms and how they scale: in-memory session ties you to one server, Redis session store enables horizontal scaling, JWT removes server-side state entirely.

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)

Client: “Give me the dashboard page”
Server: fetch data, run logic, render HTML
Server: returns complete HTML
Client: displays it

Fast first load · SEO-friendly · Server bears computation cost

Thick Client (SPA)

Client: “Give me the user’s messages”
Server: auth check, DB query
Server: returns raw JSON
Client: runs logic, renders UI

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.

Horizontally scaled chat application: three client devices connect to a load balancer, which distributes to two identical app servers, both connected bidirectionally to a central Redis pub/sub broker
Stateless app servers + Redis pub/sub: the load balancer can route any client to any server, and messages flow through Redis regardless of which server handles each connection.

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 typing event 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

client-server http stateless sessions jwt websockets architecture

Get in Touch

Have an idea or need help with your digital product? Fill out the form and we will get back to you as soon as possible.

info@webcode.mk
+389 7x xxx xxx
Skopje, North Macedonia