Skip to content
System Design

Learn

Design WhatsApp

Designing a chat system: persistent WebSocket connections, the session registry, message ordering and idempotency, delivery receipts, group fanout, and end-to-end encryption.

4 min readUpdated 2 Sept 2026

#WebSockets#Messaging#Real-time#Encryption

Chat is the canonical stateful connection problem. Unlike a request/response service, the server must know where every user is connected right now in order to push to them — and that registry is the design.

Requirements

Functional

  • One-to-one and group messaging.
  • Delivery states: sent, delivered, read.
  • Online/last-seen presence.
  • Offline users receive messages on reconnect.
  • Media attachments; end-to-end encryption.

Non-functional

  • Message delivery under ~100 ms for online users.
  • No message ever lost.
  • Ordering consistent within a conversation.
2B users, 500M concurrent connections
100B messages/day ≈ 1.2M messages/second
Message size ~100 bytes of text; media handled separately

Why WebSockets

HTTP polling wastes a request per interval per user and adds latency. Long polling is better but still reconnects constantly. A WebSocket is one persistent bidirectional connection: the server can push the instant a message arrives, and the connection carries its own heartbeat for presence.

The cost is that connections are stateful, which drives everything below. 500M concurrent connections at roughly 100K per box is thousands of gateway servers.

Architecture

Client ⇄ WebSocket gateway (stateful, holds connections)
              │
              ├─ Session registry (Redis): user_id → gateway_id
              │
              ├─ Message service ──→ message store (Cassandra)
              │        │
              │        └─→ queue → push notification service (APNs / FCM)
              │
              └─ Presence service

The session registry is the crux. When Ana sends to Ben, the message service looks up

which gateway holds Ben's connection and forwards the message there. If Ben is offline, persist it to his inbox and send a push notification instead.

Gateways must be thin: no business logic, just connection handling, so one can die and its clients reconnect elsewhere without losing anything. On disconnect, remove the registry entry — and give registry entries a TTL, because a crashed gateway never cleans up after itself.

Message storage

messages   (conversation_id, message_id, sender_id, content, created_at, …)
           partition key: conversation_id
           clustering key: message_id DESC

Partitioning by conversation makes "load the last 50 messages" a single sequential read. Cassandra or a similar wide-column store fits: write-heavy, no joins, naturally time-ordered within a partition.

Message ids should be time-sortable and client-generated — a Snowflake-style id or a UUID plus a timestamp. Client-generated matters because it makes the send idempotent: a client that retries after a network blip reuses the same id, and the server upserts rather than creating a duplicate. This is the detail that separates a considered answer from a sketch.

Ordering is per conversation, by server-assigned sequence number. Ordering by client clocks does not work — device clocks disagree.

Delivery receipts

The familiar three states are a small state machine, each transition an event travelling back to the sender:

✓      sent       server has persisted it
✓✓     delivered  recipient's device has acknowledged receipt
✓✓ blue read      recipient's client reported the chat as open

Acknowledgements flow back through the same connection, and the sender's client updates. Read receipts are a privacy setting, so the state machine must tolerate never reaching the final state.

Group messaging

For small groups — WhatsApp caps at around 1,000 — fan out on write: write one message row per recipient inbox, or one shared conversation row plus a per-member read pointer. The shared row plus pointers is cheaper and is what most designs choose.

Very large groups or broadcast channels need the celebrity treatment: do not fan out; let members pull.

Offline delivery and push

Undelivered messages sit in the recipient's inbox with a per-device delivery cursor. On reconnect the client asks for everything after its last received id — the same

cursor-based catch-up used in file sync.

For a device that is offline or backgrounded, the queue triggers APNs or FCM. Because the payload is end-to-end encrypted, the notification usually carries only a wake-up signal and the client fetches the real message — a nice illustration of encryption constraining the architecture.

End-to-end encryption

Keys live on devices; the server stores and relays ciphertext it cannot read. The Signal protocol establishes a shared secret via X3DH and ratchets keys forward per message, giving forward secrecy.

The architectural consequences are what to talk about: **the server cannot search messages, cannot generate previews, cannot deduplicate media across users, and cannot do server-side spam filtering on content.** Multi-device support requires per-device keys and re-encrypting per device. Group encryption uses a sender key distributed to each member, so a member leaving forces a key rotation.

Follow-ups to expect

  • Presence at scale — updating "last seen" on every heartbeat is a huge write load;

batch it, accept staleness, and only push presence to users with the chat open.

  • Media — uploaded to blob storage with a key shared in the encrypted message; the

server only ever holds an encrypted blob.

  • Multi-device sync — per-device cursors and per-device encryption keys.
  • Gateway failure — clients reconnect with exponential backoff and jitter to avoid a

thundering herd onto the remaining gateways.

  • Message ordering across devices — server sequence numbers, not client clocks.

Track this problem on the System Design sheet.

Frequently asked

How does a chat server know where to send a message?

Through a session registry — typically Redis — mapping each user id to the gateway server currently holding their WebSocket connection. The message service looks up the recipient's gateway and forwards the message there; if there is no entry, the user is offline, so the message is persisted to their inbox and a push notification is sent instead. Entries need a TTL, since a crashed gateway cannot clean up its own.

Why use WebSockets instead of HTTP polling for chat?

A WebSocket is a single persistent bidirectional connection, so the server can push a message the instant it arrives rather than waiting for the next poll, and the same connection provides heartbeats for presence. Polling wastes a request per interval per user and adds latency. The cost is that connections are stateful, which is why a session registry is needed.

How do you prevent duplicate messages when a client retries?

Have the client generate the message id before sending. If the network drops after the server persisted the message but before the acknowledgement arrived, the client retries with the same id and the server upserts rather than inserting a second row. Server-generated ids cannot provide this, because the client has no way to refer to the message it may or may not have created.

Related