Design a Chat System (WhatsApp / Messenger / Slack)
Difficulty: Tier 2 Asked at: Meta, Careem, Talabat, Slack, Amazon Time budget: 45–60 min
Chat flips the feed problem on its head. Feeds are read-heavy pull systems; chat is a real-time,
push, stateful-connection system — messages must arrive in milliseconds, in order, exactly once, even
when the recipient is offline. The heart of this question is persistent connections (WebSockets),
presence, and delivery guarantees. It’s the canonical push-messaging design.
Prerequisites: WebSockets & SSE, Message Queues, Idempotency
1. Requirements
Functional:
- 1:1 messaging; group chats.
- Real-time delivery; online/offline presence; typing indicators.
- Delivery/read receipts (sent ✓, delivered ✓✓, read).
- Message history/persistence; deliver messages sent while a user was offline.
- Media attachments (reference file storage).
Non-functional:
- Low latency — messages in < 100 ms end to end.
- Reliable & ordered — no lost messages; messages appear in the right order; no duplicates.
- Scale — billions of users, hundreds of millions of concurrent connections.
- High availability.
Out of scope: end-to-end encryption internals (mention it), voice/video calls.
2. Estimation
- 1B users, 100M concurrent connections, ~50 messages/user/day → ~50B messages/day ≈ 600K
messages/sec, peak higher.
- 🚨 100M concurrent WebSocket connections is the defining number — you need many connection servers,
each holding ~1M connections (a tuned server can), so ~100+ connection servers just to hold sockets.
- Storage: 50B messages/day × ~200 bytes → ~10 TB/day → petabytes. Wide-column store.
3. API / protocol
Not REST for the message path — a persistent connection:
WebSocket connect → authenticated, long-lived
send: { type: "message", to, chatId, clientMsgId, text }
receive: { type: "message", from, chatId, msgId, text, ts }
{ type: "receipt", msgId, status: "delivered"|"read" }
{ type: "presence", userId, status: "online"|"offline" }
REST for history/media: GET /chats/{id}/messages?before=..., POST /media.
🚨 WebSockets, not polling. Polling for new messages at this scale is wasteful and slow; a persistent
connection lets the server push instantly. (WebSockets & SSE)
4. High-level design
flowchart TB
A[User A] <-->|WebSocket| CS1[Connection Server 1]
B[User B] <-->|WebSocket| CS2[Connection Server 2]
CS1 --> Router[Message Router / Service]
CS2 --> Router
Router --> Store[(Message store<br/>wide-column)]
Router --> Presence[(Presence store<br/>Redis)]
Router --> Registry[(Connection registry<br/>user → server)]
Router --> Push[Push service<br/>APNs/FCM for offline]
Send flow: A sends over its WebSocket to Connection Server 1 → the message router persists the message,
looks up where B is connected (connection registry: user → server) → forwards to Connection Server 2 →
pushes down B’s WebSocket. If B is offline, store it and send a mobile push notification.
🚨 The connection registry (which server holds which user’s socket) is the key piece that lets one
server route a message to a user connected to a different server.
5. Deep dives
5a. Managing millions of connections
Connections are stateful — a user is pinned to one connection server for the life of the socket.
Connection servers are horizontally scaled; a service discovery/registry (Redis) maps user_id →
connection_server. When A messages B, the router uses the registry to find B’s server and forwards there.
Heartbeats/pings detect dead connections; on disconnect, update presence and the registry.
5b. Delivery guarantees & ordering
- Persistence first: the message is written to the store before acknowledging to the sender → never
lost even if delivery fails. Sender sees “sent ✓.”
- At-least-once delivery to the recipient + idempotency: each message carries a
clientMsgId; the
recipient dedupes so a redelivered message isn’t shown twice. (Idempotency)
- Ordering: assign a per-chat sequence number / time-sortable ID; the client orders by it. Within a
chat, messages are totally ordered; across chats, order doesn’t matter.
- Receipts: delivered when it reaches the device; read when the user opens the chat — sent back as
receipt messages.
5c. Offline delivery
If the recipient is offline (no active socket), persist the message in their inbox / mailbox and send a
push notification (APNs/FCM). When they reconnect, they sync undelivered messages from the store
(everything since their last-received sequence number). 🚨 This “store-and-sync on reconnect” is what makes
chat reliable across flaky mobile networks.
5d. Group chats & fan-out
A group message must reach all members. For small/medium groups, fan out to each member’s connection (or
inbox if offline) — like a mini feed fan-out. For very large groups (Slack channels, broadcast lists),
fan-out on read or a hybrid, to avoid amplification. Store the message once; deliver references.
5e. Presence
Presence (online/offline/last-seen) is high-churn. Store in Redis, updated on connect/disconnect and
heartbeats. Broadcasting every presence change to everyone is expensive — only push presence for a user’s
contacts / open chats, and use a short TTL so a crashed connection expires to offline automatically.
6. Bottlenecks & scaling further
- Concurrent connections → many connection servers; registry maps users to servers.
- Routing across servers → connection registry + a message router / pub-sub between servers.
- Message storage → wide-column store sharded by chat/user; time-sortable IDs.
- Offline sync → per-user mailbox + sync-since-sequence on reconnect.
- Presence churn → Redis + TTL, scoped broadcasts.
- Group amplification → hybrid fan-out for large groups.
7. Trade-off summary
| Decision |
Chosen |
Alternative |
Why |
| Transport |
WebSocket (push) |
HTTP polling |
Instant delivery; no wasteful polling at scale |
| Connection state |
Stateful, pinned to a server |
Stateless |
Long-lived sockets must live somewhere; registry routes |
| Delivery |
At-least-once + idempotency |
Exactly-once (hard) |
Reliable without impossible guarantees |
| Ordering |
Per-chat sequence numbers |
Global order |
Only per-chat order matters; cheaper |
| Offline |
Persist + push + sync |
Drop or block |
Reliable over flaky mobile networks |
8. Follow-up questions
How does a message reach a user connected to a different server?
Through the connection registry. Each connection server holds the live WebSockets for the users pinned to
it, and a shared registry (e.g. Redis) maps every online user to the server currently holding their socket.
When user A (on server 1) sends to user B, the message router persists the message, looks up B in the
registry, sees B is on server 2, and forwards the message to server 2 (via an internal RPC or a pub-sub
channel between servers), which pushes it down B's socket. So no single server needs everyone's connection —
the registry lets any server deliver to any user by locating their server first.
How do you guarantee a message is never lost but also never shown twice?
Persist before acknowledging, and dedupe on delivery. The message is written durably to the store before the
sender gets its "sent" ack, so even if the recipient's delivery fails, the message survives and can be
re-delivered on reconnect — nothing is lost. Delivery to the recipient is at-least-once (retried until
acked), which can cause duplicates, so each message carries a client-generated ID and the recipient (and the
store) dedupe on it — a redelivered message with a seen ID is dropped, not displayed again. This combination
gives the practical "exactly-once *effect*" without needing true exactly-once delivery, which is effectively
impossible across unreliable networks and devices.
A user was offline for a day. How do they get their messages on reconnect?
Messages sent while they were offline were persisted to their per-user mailbox/inbox (and triggered a push
notification at the time). Each user tracks the sequence number / timestamp of the last message they've
received. On reconnect, the client sends that watermark and the server streams every message since it,
in order, so the client syncs the full backlog. Delivery is idempotent, so any message that was
mid-flight when they dropped won't be duplicated. This store-and-sync-on-reconnect model is what makes chat
robust to the constant disconnects of mobile networks.
How do you keep presence (online status) cheap at a billion users?
Store presence in an in-memory store (Redis) updated on connect/disconnect and periodic heartbeats, and use
a short TTL so that if a connection dies without a clean disconnect, the key simply expires and the user
flips to offline automatically — no explicit cleanup needed. Crucially, don't broadcast every presence
change to everyone: only notify the people who care — a user's contacts or the participants of chats they
have open — which bounds the fan-out. Presence is high-churn, so scoping who gets updates and relying on TTL
expiry instead of exact bookkeeping keeps it affordable.
How would you handle a 10,000-member group?
Don't fan out one message to 10,000 sockets synchronously. Store the message once, and for large groups use
a fan-out-on-read or hybrid model: online members can be pushed a lightweight notification/reference and
pull the message, while offline members get it from their mailbox on reconnect. For truly broadcast-scale
groups (huge Slack channels), this looks like the celebrity problem from the feed designs — you avoid
amplification by not eagerly delivering to every member and instead let clients pull, with the message
stored a single time. Small and medium groups can simply fan out to each member's connection/inbox.
9. What junior / mid / senior answers look like
- Junior: proposes HTTP polling or a simple send-to-DB, recipient polls. Works tiny-scale; misses
real-time push and offline delivery.
- Mid: WebSockets, a connection registry to route across servers, persist-then-deliver, offline mailbox
- push, ordering via sequence numbers.
- Senior: all that plus the stateful-connection scaling story (100M sockets, many servers, registry),
at-least-once + idempotency for exactly-once effect, sync-on-reconnect, presence with TTL and scoped
broadcasts, and hybrid fan-out for large groups — framing chat as a push/stateful system in contrast to
pull-based feeds.
Further reading