system-design

Client–Server, Peer-to-Peer, and Everything Between

How the pieces of a system relate to each other — and why “who initiates the connection” turns out to be a design decision with enormous consequences.

Prerequisites: Networking 101 Time to read: ~14 minutes


The problem

Two programs need to exchange data. Someone has to start the conversation, and someone has to be reachable. That sounds trivial, but the choice determines:

Get this wrong and you end up trying to push server updates to clients that cannot be reached, or trusting a price calculation done in a browser.


Client–server

One party (the server) waits at a known address. The other (the client) initiates.

flowchart LR
    C1[Client] --> S[(Server)]
    C2[Client] --> S
    C3[Client] --> S

Why it dominates:

The consequences you design around:

Thin vs thick clients

  Thin client Thick client
Logic lives Server Client
Examples Server-rendered pages, terminals SPAs, mobile apps, desktop apps
Update speed Instant — deploy the server Slow — app store review, users who never update
Offline Impossible Possible
Server load Higher Lower
Trust Server-enforced by default Must re-validate everything server-side

🚨 The mobile version-skew problem is a real design constraint people forget. Once you ship a mobile app, some users are on a version from two years ago and will never update. Your API must support them or you break real customers. This is why API versioning and backward compatibility matter far more for mobile than for web.


Who initiates: the push problem

Client–server has one structural gap: the server can’t start a conversation. The client is behind a NAT, on a phone, with no reachable address.

But you constantly need server-initiated updates: a chat message arrives, a ride is matched, a price changes, a notification fires. So the industry invented a set of workarounds, and choosing between them is a common interview question.

Technique How Latency Server cost Use when
Short polling Client asks every N seconds Up to N seconds Wasteful — most requests return nothing Simple, low-frequency updates; N is minutes
Long polling Client asks; server holds the request open until there’s news Near-instant One held connection per client Good fallback; simple to reason about
SSE (Server-Sent Events) One long-lived HTTP stream, server→client only Near-instant One connection per client Feeds, notifications, live dashboards
WebSocket Full-duplex persistent connection Near-instant One connection per client Chat, collaboration, gaming — anything bidirectional
Push notifications Via APNs/FCM, works when the app is closed Seconds Offloaded to Apple/Google Mobile, app not running

📐 The cost that decides it: persistent connections mean stateful servers. 1 million concurrent WebSocket users at ~10 KB kernel state each is ~10 GB of connection memory, before your application data. And each user is pinned to a specific server, so you need a way to route a message to whichever server holds that user’s connection — usually a Redis pub/sub layer or a connection registry.

That routing problem is the heart of the chat system design question. → Realtime Communication


Peer-to-peer

Every node is both client and server. There’s no central authority.

flowchart LR
    A[Peer A] <--> B[Peer B]
    B <--> C[Peer C]
    A <--> C
    C <--> D[Peer D]
    A <--> D

What it buys you:

What it costs you:

Where it’s actually used: BitTorrent, WebRTC media (video calls connect peer-to-peer where possible), blockchain networks, IPFS, and some CDN-offload products that have browsers serve video chunks to each other.

🚨 In an interview, proposing pure P2P for a consumer product is usually wrong unless bandwidth cost is the dominant constraint (video distribution) or decentralization is an explicit requirement. But mentioning the hybrid — “peer-to-peer media with a central signalling server” — is a strong answer for video calling.


Hybrid architectures — what real systems actually are

Almost nothing is purely one model:

The lesson: pick the model per data path, not per system. Control plane and data plane often want different answers.


The tiers

The other axis people mean by “architecture” is how many layers the server side has:

Two-tier — client talks directly to the database. Fine for a desktop tool on a LAN; catastrophic on the internet (credentials in the client, no validation, no rate limiting).

Three-tier — the default, and the right starting answer for almost every design:

flowchart LR
    P[Presentation<br/>web / mobile] --> A[Application<br/>business logic, APIs]
    A --> D[(Data<br/>database, cache)]

N-tier — the application layer splits further into services, gateways, queues, and workers. That’s microservices, and you should only reach for it when the problem justifies it.


Stateless vs stateful — the property that governs scaling

This distinction deserves its own emphasis because it decides whether horizontal scaling works at all.

Stateless server: keeps no client data between requests. Every request carries everything needed. Any instance can serve any request. → You can add or kill servers freely. A crash affects only the in-flight request.

Stateful server: holds session data, a WebSocket connection, an in-progress upload, or a game room in memory. → That user must reach that server. A crash loses real state. Deploys are disruptive.

The standard move: push state out of the app servers into a shared store — sessions in Redis, files in object storage, jobs in a queue. Then the app tier is stateless and trivially scalable, and the hard state problem is concentrated in systems designed for it.

Where you genuinely can’t (WebSocket connections must live somewhere), you accept stateful servers and add: a connection registry so messages can be routed, graceful drain on deploy, and client auto-reconnect logic. → Scalability


⚖️ Trade-offs

Model Gain Cost
Client–server Control, consistency, simplicity You pay for all resources; single point of failure
Peer-to-peer Bandwidth scales with users; censorship-resistant Discovery, NAT traversal, trust, no consistency
Thin client Instant updates, nothing to trust on the client Higher server load, no offline, more round trips
Thick client Offline, responsive, less server load Version skew forever; must duplicate validation server-side
Polling Trivial, stateless, works everywhere Wasted requests, latency bounded by interval
Persistent connections Instant delivery Stateful servers, memory per connection, routing complexity

🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

Build the same tiny feature — “show a live count of connected users” — three ways:

  1. Short polling: client hits GET /count every 2 seconds.
  2. SSE: server streams updates on GET /count/stream.
  3. WebSocket: bidirectional connection, server pushes on change.

Open 50 browser tabs against each. Watch the request count in your server logs, and the memory used by the process. The difference in server load between #1 and #3 — and the difference in complexity — is the trade-off, made concrete.


Check yourself

1. Why can't a server just open a connection to a mobile client when it has news? The client almost certainly has no reachable public address — it's behind carrier NAT or a home router that blocks inbound connections, its IP changes as it moves, and the OS may have suspended the app entirely. So the client must initiate and keep a connection open (WebSocket/SSE/long poll), or you go through a platform push service (APNs/FCM) that maintains one OS-level connection on behalf of every app.
2. What's the real cost of choosing WebSockets over polling? Statefulness. Each connection consumes memory and a file descriptor on a *specific* server, so: capacity planning becomes connection-count-driven; you need a registry or pub/sub layer to route a message to the right server; deploys must drain connections gracefully; and clients need reconnect with backoff. You trade a simple stateless tier for instant delivery.
3. When is peer-to-peer actually the right answer? When bandwidth is the dominant cost and content is identical for many users (video/file distribution), when low latency between two specific users matters more than central control (video calls), or when decentralization is itself a requirement. Otherwise the discovery, NAT traversal, and trust costs outweigh the savings.
4. Your API adds a required field. Web works; mobile users report crashes. What went wrong? Version skew. Web clients all got the new code on refresh; mobile clients are running builds from months ago that don't send the new field, and many users will never update. Any API change must be backward compatible: make new fields optional with sensible defaults, never repurpose existing fields, and version the API when a breaking change is unavoidable.
5. Why is "stateless" the key enabler of horizontal scaling? Because if a server holds no client-specific state, any request can go to any instance. That means you can add capacity by adding machines, remove machines without draining anything, survive instance death with no user-visible loss, and load balance freely. The moment a server holds state, requests must be routed to a *specific* machine, and every one of those properties weakens.

Further reading