Walkthrough: Chat System
WebSocket connection management, message ordering and delivery guarantees, presence, and group-chat fan-out.
"Design WhatsApp/Messenger" is the canonical real-time question. The heart of it: user A is connected to server 1, user B to server 2 — how does a message cross? Everything else hangs off that.
Step 1: Requirements
Functional:
- Send and receive 1:1 messages in real time.
- Messages are delivered even if the recipient is offline (delivered on reconnect).
- Group chats up to 100 members.
Non-functional:
- Low delivery latency (feels instant — sub-second).
- No lost messages — delivery is at-least-once, and we'll handle duplicates.
- Scale: 10M concurrent connections.
Step 2: Estimation where it decides something
A modern app server holds 100k+ concurrent connections, so 10M concurrent users ≈ ~100 chat servers — a modest fleet, not thousands. This number matters because it tells you the connection-routing problem is real but tractable.
Step 3: Core entities
User, Chat (1:1 or group, with members), Message (message_id, chat_id, sender_id, content, sequence, created_at), Inbox (per-recipient queue of undelivered message IDs).
Step 4: Interface
Chat is truly bidirectional — the server must push messages down without being asked — so this is the textbook case for WebSockets (a persistent two-way connection that stays open after one HTTP handshake). Over the socket:
client -> server: send_message { chat_id, client_msg_id, content }
server -> client: new_message { message_id, chat_id, sender, content, seq }
server -> client: ack { client_msg_id, message_id }
Plain REST still handles login and fetching chat history. One infra note that earns points: WebSockets are long-lived TCP connections, so balance them with a Layer 4 load balancer (routes on TCP, doesn't inspect HTTP).
Step 5: High-level design
Each chat server keeps an in-memory connection map: user_id → socket handle, for the users connected to it. The cross-server hop uses Redis pub/sub (a broadcast channel: publish once, every subscriber hears it):
- A's message arrives at server 1.
- Server 1 persists it (details next), then publishes to channel
user:B. - Server 2, which subscribed to
user:Bwhen B connected, receives it and pushes it down B's socket.
The alternative — consistent-hashing users to specific servers so you always know where B lives — avoids the pub/sub hop but makes reconnects and scaling messier. Name both; pub/sub is the cleaner mid-level pick.
Deep dive 1: Durability — persist, then publish
Here is the nuance that separates candidates: pub/sub is fire-and-forget. If B's server misses the publish (B offline, server restarting), the message is simply gone — pub/sub alone is at-most-once. So durability must come from the database, not the channel:
- Write the message to the Messages table and an entry to B's Inbox table (DynamoDB works well — simple key-value access by recipient).
- Then publish for real-time push.
- When B's client receives it, it sends an ack, which deletes the inbox entry.
- When B reconnects after being offline, the client drains its inbox — everything undelivered is still there.
An inbox per client (not per user) supports multi-device. Purge delivered/expired entries with a TTL (~30 days).
Deep dive 2: Ordering and duplicates
Timestamps from phones can't be trusted (clocks skew), so order by server receive time, and give each message a per-chat sequence number. Clients use sequence numbers for gap detection: if you have seq 41 and 43, request 42.
Delivery is at-least-once (persist + push + inbox drain can overlap), so clients dedupe by message_id — receiving the same ID twice displays once. At-least-once plus idempotent handling is the standard guarantee; say the phrase.
Deep dive 3: Presence and dead connections
TCP won't tell you promptly that a phone dropped off — timeouts take minutes. Use application-level heartbeats: the client pings every few seconds; miss ~3 and the server marks the user offline, updates last-seen, and cleans the connection map.
Deep dive 4: Groups and media
Group message (≤100 members): persist the message once, then fan out one inbox entry per member and publish to each — at most 100 cheap writes, which is exactly why capping group size in requirements was smart. Media (photos/videos) never flows through chat servers: the client gets a presigned URL (a temporary direct-upload link to blob storage) and sends only the resulting URL as the message body.
Wrap-up
Recap: real-time 1:1 via WebSockets + pub/sub routing; no lost messages via persist-then-publish and inbox drain on reconnect; groups fan out to ≤100 inboxes. We chose at-least-once delivery with client dedupe, and L4 balancing for the sockets. With more time: end-to-end encryption and typing indicators.