Design a chat system: WebSocket gateways, presence, per-conversation storage, delivery and acknowledgements, ordering, offline push, group fan-out and encryption.
What you'll learn: By the end of this lesson you will be able to (1) design a gateway tier that holds millions of long-lived connections, (2) store and order messages per conversation, (3) explain delivery acknowledgements, offline delivery and group fan-out, and (4) describe the main encryption options and their consequences.
Think of a post office with a very particular rule: letters must reach the recipient within a second if they are standing at their front door, and must be held safely if they are not home. The post office needs to know who is home (presence), keep a line open to every house that is (connections), store letters for the absent (offline storage), and tap on the window of those who stepped out (push notifications).
A chat system is that post office. The hard parts are not the messages themselves, which are tiny. The hard parts are keeping millions of connections open, knowing which server each user is attached to, keeping messages in order, and never losing one that was acknowledged.
We assume the Foundations and Building Blocks courses, especially message queues and sharding.
The core idea
Separate three jobs that are easy to mix up:
Connection handling: a fleet of stateful gateway servers holding long-lived WebSocket connections.
Message routing: deciding which gateways should receive a message right now.
Message storage: a durable, per-conversation log that is the source of truth.
The durable log makes delivery reliable: a message is stored first, then delivered, and the client confirms. The gateways are just a fast lane on top of storage.
Requirements and scope
Functional: one-to-one chat, group chat (up to a few hundred members in this design), message history, delivery and read receipts, online presence, push notifications for offline users, multi-device sync.
Out of scope: voice and video calls, very large broadcast channels (they behave more like a news feed, see the news feed case study), media storage internals (see the photo sharing case study; attachments are uploaded to object storage and referenced by URL).
Non-functional (assumptions):
Message delivery to an online recipient: p99 well under a second.
No acknowledged message is lost (durability).
Messages within one conversation appear in the same order for all participants.
Availability over perfect consistency for presence. Slightly stale "last seen" is fine.
Back-of-envelope estimate
All numbers are illustrative assumptions.
Quantity
Assumption
Result
Daily active users
100 million
Concurrent connections at peak
20% online at once
20 million connections
Messages sent per user per day
40
4 billion messages per day, about 46,000 per second average
Peak factor
3x
about 140,000 messages per second
Average message size
200 bytes of content plus metadata, say 300 bytes stored
1.2 TB per day, about 440 TB per year before replication
Connections per gateway
100,000 (assumption, depends on memory and tuning)
200 gateways at peak, more for headroom
Group fan-out
Average group of 5, a few big ones of 200
Deliveries are a few times the number of messages
Connection count, not message rate, sizes the gateway tier. Memory per connection (buffers, TLS state, session data) usually bounds how many one machine holds. Message rate and storage size size the storage tier.
API design
Clients speak a framed protocol over a WebSocket (a long-lived bidirectional connection started from an HTTP upgrade). The HTTP API handles everything that is not real-time.
WS /v1/connect authenticated upgrade (credential in a header, cookie or first frame, not in a logged URL)
client -> server: { "type": "send", "convId": "c_9", "clientMsgId": "uuid", "body": "hi" }
server -> client: { "type": "ack", "clientMsgId": "uuid", "msgId": "m_123", "seq": 481 }
server -> client: { "type": "message", "convId": "c_9", "msgId": "m_123", "seq": 481, "from": "u_1", "body": "hi" }
client -> server: { "type": "delivered", "convId": "c_9", "upToSeq": 481 }
GET /v1/conversations/c_9/messages?beforeSeq=481&limit=50
POST /v1/conversations create group
Two fields matter. clientMsgId is generated by the client so a retry after a dropped connection does not create a duplicate (idempotency). seq is a per-conversation sequence number assigned by the server, which defines order.
Data model
Messages are append-only and read by conversation, newest first. That access pattern suits a partitioned wide-column store (Cassandra-style) or a sharded key-value or relational store.
Messages: partition key conv_id, clustered by seq descending. One conversation lives on one partition, so history reads are sequential and fast. Row: (conv_id, seq, msg_id, sender_id, body, created_at). De-duplication by clientMsgId needs its own lookup, for example a small table or cache keyed by (conv_id, client_msg_id) with a TTL, since the main table is ordered by seq.
Conversations and membership:conversation(conv_id, type, ...) and members(conv_id, user_id) with a reverse index user_conversations(user_id, conv_id, last_seq, last_read_seq) to render the inbox.
Sessions: which gateway holds which device connection: session(user_id -> [device_id, gateway_id]). This is small, hot and ephemeral, so it fits an in-memory store with TTLs. It is refreshed by heartbeats.
Offline queue / sync cursor: each device remembers the last seq it has per conversation. Sync is "give me everything after seq N", so no separate per-user queue is strictly required.
NOTE
Very large conversations make a single partition grow without bound. Add a time bucket to the partition key (for example conv_id plus month) so partitions stay a manageable size.
erDiagram
USERS ||--o{ MEMBERS : joins
CONVERSATIONS ||--o{ MEMBERS : has
CONVERSATIONS ||--o{ MESSAGES : contains
USERS ||--o{ SESSIONS : connects
CONVERSATIONS {
string conv_id PK
string type
long last_seq
}
MEMBERS {
string conv_id FK
string user_id FK
long last_read_seq
}
MESSAGES {
string conv_id PK
long seq PK
string msg_id
string sender_id
string body
}
SESSIONS {
string user_id FK
string device_id
string gateway_id
}
How to read it: MESSAGES is keyed by conv_id and seq, which is exactly the history read path. SESSIONS is the only ephemeral table and can be rebuilt from heartbeats.
High-level design
Gateways are mostly dumb: terminate TLS, authenticate, keep the socket, and forward frames to the message service. The message service validates, assigns the sequence number, stores the message, then asks the router to deliver. The router looks up recipients' sessions and hands the message to the right gateways. If a recipient has no live session, it goes to the push service.
sequenceDiagram
participant A as Sender
participant G1 as Gateway 1
participant M as Message service
participant S as Message store
participant R as Router
participant G2 as Gateway 2
participant B as Recipient
A->>G1: send with clientMsgId
G1->>M: forward
M->>S: assign seq and persist
M-->>G1: ack with seq
G1-->>A: ack
M->>R: route to recipients
R->>G2: deliver
G2->>B: message
B-->>G2: delivered up to seq
G2->>M: record delivery
The sender's ack means "stored", not "delivered". Delivery and read are separate, later events. That distinction is what turns "sent, delivered, read" ticks into honest status.
stateDiagram-v2
[*] --> Sending
Sending --> Sent : server stored and acked
Sending --> Sending : retry same clientMsgId
Sent --> Delivered : recipient cursor passes seq
Delivered --> Read : read cursor passes seq
Sending --> Failed : retries exhausted
Failed --> Sending : user taps retry
Read --> [*]
How to read it: only the server ack moves a message out of Sending, and later states are driven by cursors, not by individual receipts.
Reference deployment topology
The deployed system has a stateful gateway tier in front of the services (the message service is partitioned by conversation, the router and push service are stateless), with the log and the session store in the data tier.
How to read it: gateways are the only tier that holds connections, so they are the only tier whose deploys need draining. The message service is partitioned by conversation so one owner assigns each seq.
Deep dive 1: connection gateways and presence
Why a stateful tier. WebSockets are long-lived, so the server holds per-connection state. That breaks the usual "any server can handle any request" rule for this tier. Behind the gateways, durable state lives in the stores, and the only per-process state is partition ownership in the message service.
Load balancing connections. An L4 or L7 load balancer spreads new connections, and because a connection lasts hours, balancing happens at connect time. Deploys and scale-down must drain gracefully: tell clients to reconnect elsewhere, with random jitter and a capped reconnect rate. A deploy that drops 100,000 connections per node, all reconnecting at once, creates a reconnect storm that can take down the next tier.
Session lookup. On connect, the gateway writes (user, device) -> gateway into the session store with a TTL, renewed by heartbeat. The router queries it to find where to send. If the entry is stale (the gateway died), delivery fails fast and the message falls back to storage and push. Clients reconnect and sync by sequence number, so nothing is lost.
Presence. The client sends a heartbeat every N seconds, the gateway refreshes a key with a TTL slightly above N, and expiry means offline. Do not broadcast every change to every contact: let clients subscribe only to conversations on screen, and debounce flapping with a short delay before announcing "offline". Presence is the classic place to accept eventual consistency and approximate values.
Approach to presence
Use when
Cost
Alternative
Heartbeat with TTL key
Default, simple
Heartbeat traffic, up to one TTL of staleness
Connection-state events only
Fan-out to all contacts
Tiny friend lists
Explodes with large contact lists
Subscribe only for visible chats
Pull on demand
Rarely shown presence
Higher latency on open
Cache with short TTL
Deep dive 2: ordering, acknowledgements and delivery guarantees
Ordering. Clocks across devices disagree, so timestamps cannot define order (see IDs, clocks and ordering). Instead, route all writes for a conversation through a single logical owner that assigns an increasing seq. In practice, partition the message service by conv_id (hash the conversation ID to pick a partition) and process each partition's messages serially. Clients then sort by seq. Ordering is guaranteed per conversation, not globally, and that is all users need.
If the owner fails, another node takes over the partition. Its leadership must be established safely (see consensus and leader election) so two nodes never assign the same seq. A message log such as Kafka gives per-partition ordering for free if you key by conv_id (see event streaming), though you still have to design the consumer side.
Delivery semantics. The honest guarantee is at-least-once delivery with idempotent receipt. The server retries until the client acknowledges, and the client discards duplicates using msgId or seq. The effect of "exactly once" comes from that combination, not from the network itself. Clients also detect gaps: if they have seq 480 and receive 482, they request 481 from history.
The reliable-send loop from the sender:
Generate clientMsgId, show the message locally as "sending".
Send over the socket. If no ack within a timeout, retry with the same clientMsgId.
The server de-duplicates by (conv_id, clientMsgId), returns the original seq.
Show "sent" on ack.
Delivered and read receipts. Treat them as cumulative cursors: "delivered up to seq 481" and "read up to seq 479" per user per conversation. A cursor is smaller and cheaper than one receipt per message and survives lost packets, because a later cursor covers earlier ones.
Multi-device. Each device holds its own socket and its own sync cursor. Delivery fans out to all of a user's devices. Read cursors are synchronised so reading on the phone clears the badge on the laptop.
Deep dive 3: offline delivery and group chat
Offline users. The message is already in the store, so nothing needs a special queue. The router sees no live session and calls the push service, which sends a notification through the platform push providers (APNs for Apple devices, FCM for Android). Push is best-effort: it can be delayed, dropped or throttled, and it should carry minimal content (or just a "new message" hint) because it passes through third parties. When the app opens, it connects and syncs from its last seq. Push is a nudge, the log is the truth.
Group chat fan-out. A group message is stored once (one row in the conversation's partition) and delivered to each member. For small groups, the router fans out to each member's sessions. The scale lever is the same one as in feeds: push to online members now, let offline members catch up by sync. For larger groups, route per gateway instead of per member: send one copy to each gateway that has any member, and let the gateway fan out locally. That cuts internal traffic from "members" to "gateways involved".
Group delivery approach
Use when
Cost
Alternative
Per-member delivery via router
Small groups
Many internal messages for big groups
Per-gateway batching
Per-gateway batching
Groups with hundreds of members
Gateways need a local member index
Pub/sub channel per group
Pub/sub topic per group
Very large rooms
Subscription management at scale
Treat as a broadcast feed
The choice of group delivery path follows from room size and how clustered the members are.
flowchart TD
M["Group message stored"] --> A{"Members under about 20?"}
A -->|yes| B["Router delivers per member session"]
A -->|no| C{"Members under a few hundred?"}
C -->|yes| D["One copy per gateway, local fan-out"]
C -->|no| E["Pub sub topic or treat as broadcast channel"]
B --> F["Offline members get push and sync"]
D --> F
E --> F
How to read it: the thresholds are tuning knobs, not rules. The last step is the same for every branch because the stored log is the guarantee.
Membership changes are events in the same log, so every device agrees on who was in the group at which seq. That matters for what a new member can read (history policy) and for encryption keys.
Encryption options (high level)
In transit: TLS on every connection. This is the baseline.
At rest: encrypt storage volumes or fields with keys held in a key management service.
End-to-end encryption: messages are encrypted on the sender's device and only decrypted on recipients' devices, so the server stores ciphertext it cannot read. Well-known designs use per-device keys and a ratcheting protocol to limit how much a stolen key exposes. The consequences are real: the server cannot search or moderate message content, server-side history backfill to a new device needs a client-assisted key transfer, groups need key distribution on every membership change, and multi-device multiplies keys. Do not design your own cryptography: use an established, audited protocol and library.
IMPORTANT
Choose encryption by threat model and product needs. End-to-end encryption protects content from the operator and from a server breach, but removes server-side features that rely on reading content. State this trade-off explicitly rather than treating it as a free upgrade.
Bottlenecks and scaling
Gateway memory and file descriptors. Tune per-connection buffers, use event-driven networking, and scale out horizontally. Keep gateway logic thin so a deploy is cheap.
Session store. About 20 million entries are small but hot. Shard by user_id and replicate.
Message store write rate. About 140,000 writes per second at peak is comfortable for a sharded, write-optimised store with append-heavy access. Partition by conversation, and watch for hot conversations (a huge, busy group).
Router fan-out. Batch per gateway. Keep the router stateless.
Reconnect storms. Jittered exponential backoff on the client, connection rate limits at the edge, and gradual drains.
Failure modes
Failure
Effect
Mitigation
Gateway crash
Its users disconnect
Clients reconnect with jitter and sync by seq. Session entries expire by TTL
Duplicate send after retry
Double message
De-duplicate by clientMsgId
Message store partition unavailable
Cannot persist
Reject with a retryable error, never ack unstored messages
Router sends to stale session
Message not delivered live
Fall back to push and sync, since the log has it
Sequencer owner fails over
Possible duplicate seq if unsafe
Safe leader election and fencing, or store-enforced uniqueness on (conv_id, seq)
Push provider outage
No offline notifications
Retry with backoff, rely on sync at next app open
Reconnect storm
Cascading overload
Jitter, rate limits, gradual drain
Presence flapping
Noisy UI
Debounce offline announcements
Trade-offs worth stating
Decision
Option A
Option B
How to choose
Transport
WebSocket
Long polling or server-sent events plus HTTP
WebSocket for bidirectional low latency. Fallbacks for restrictive networks
Ordering
Per-conversation sequencer
Client timestamps
Sequencer. Timestamps cannot be trusted
Delivery
At-least-once with dedup
At-most-once
At-least-once. Losing messages is worse than duplicates
Storage layout
Partition by conversation
Partition by user inbox
Conversation for history reads. Inbox view via a small index
Encryption
Server-readable
End-to-end
Threat model versus search, moderation and recovery features
Interview cheat sheet
Step
What to say
Key numbers (illustrative)
Requirements
1:1 and groups, history, receipts, presence, push
100M DAU
Estimate
Connections size gateways, messages size storage
20M connections, 140k messages/s peak, about 440 TB per year
API
WebSocket frames with clientMsgId and seq, HTTP for history
Idempotent sends
Data
Messages partitioned by conversation, session store, inbox index
Time-bucketed partitions
Design
Gateways, message service with sequencer, router, push
Store first, then deliver
Deep dives
Gateway drain and presence, ordering and at-least-once, offline and group fan-out
Cumulative receipts
Failures
Gateway loss, reconnect storm, stale sessions, push outage
Sync by seq
Trade-offs
Encryption versus features, ordering strategy, fan-out per member versus per gateway
Threat model
In practice: operating the reference design
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
Assume a team of twelve runs this on Kubernetes across three zones. The gateways use a plain WebSocket server behind an L4 load balancer, the message store is a Cassandra-style wide-column database, sessions and presence live in Redis, and Prometheus and Grafana provide monitoring.
SLOs and dashboards
SLI
SLO (assumed)
Dashboard panels
Send acknowledged
99.95% of sends acked within 500 ms
Ack latency, store write latency, retry rate
Live delivery
p99 under 1 s to online recipients
Router to gateway latency, stale-session fallback rate
Durability
Zero acked messages lost
Seq gap detector per conversation, store error rate
Connection health
99.9% of connects succeed
Connects per second, connections per gateway, reconnect rate
Push dispatch
99% handed to provider within 5 s
Push queue age, provider error codes
Capacity-review cadence
Weekly: connections per gateway against the 100,000 assumption, memory per connection, partition size outliers, hot conversations.
Monthly: storage growth against about 440 TB per year before replication, tombstone and compaction health.
Quarterly: a reconnect-storm drill (drain one zone and measure recovery time), and a load test at twice the assumed peak of 140,000 messages per second.
Before major events: holidays and large broadcasts shift message rates, so re-run the estimate.
Runbook
Symptom
Likely cause
First check
Mitigation
Messages delayed for online users
Router or session store slow
Session lookup latency, stale session rate
Scale router, bypass stale entries, fall back to sync
Connect failures spike
Reconnect storm after a deploy or incident
Connects per second by gateway, client backoff logs
Rate-limit at the edge, drain gradually, widen jitter
Gateway memory climbing
Buffer leak or slow consumers
Memory per connection, send queue depth
Disconnect slow clients, drain and restart pods in small waves
Duplicate seq error in the store
Two owners after a failover
Partition ownership changes, fencing token logs
Reject the stale owner, repair the conversation
No offline notifications
Push provider outage or credential expiry
Provider error rates and auth errors
Retry with backoff, rely on sync at open
One conversation slow
Hot partition
Partition size and write rate
Time-bucket the key, throttle the sender
Decision record: per-conversation sequencer
Field
Entry
Context
Timestamps cannot order messages and a global sequence is a bottleneck. Users need a single order per conversation.
Decision
Partition the message service by conv_id. One owner per partition assigns seq, guarded by a fencing token. As a backstop the store rejects a second write to the same (conv_id, seq), which in a Cassandra-style store needs a conditional insert (slower), so it is used selectively rather than as the only protection.
Consequences (good)
Strict per-conversation order, simple clients, cheap gap detection.
Consequences (bad)
A hot conversation limits one owner, failover needs safe leadership, and cross-conversation order is not defined.
Revisit when
One conversation sustains write rates that saturate its owner, or failovers produce more than a handful of repairs per month.
Cost view
Gateways (memory per connection), storage and cross-zone traffic dominate. Message payloads are small, so per-message compute is rarely the issue.
Cost driver
Lever
Gateway fleet
Lean per-connection buffers, tune connections per node, scale with headroom for drains
Message storage
Retention policy, compression, time-bucketed partitions, tiering old history
Cross-zone traffic
Prefer same-zone calls between gateway, message service and router where possible, batch per gateway
Push
Collapse notifications, send minimal payloads
Architect's notes
Measure memory per connection first. The gateway count follows from it, and an unmeasured estimate can be off by a wide margin.
Make clients part of the design review. Backoff, jitter and sync logic live in the apps, and a bad release can cause the outage.
Pin down the history policy early. Who can read history, for how long, and what a new group member sees affects storage, encryption and legal obligations.
Do not let the gateway become a service. Every feature added to the stateful tier makes deploys scarier.
Defer end-to-end encryption debates with facts. List which features (search, moderation, backfill) depend on server-readable content before deciding.
Page the right team. Gateways, the store and the push integration usually have different owners, so write the runbook entry points per owner.
Common mistakes
Ordering by client timestamps. Clock skew reorders messages. Assign a server-side sequence per conversation.
Acking before persisting. A crash then loses an acknowledged message. Ack only after the durable write.
Treating push notifications as the delivery channel. Push is best-effort. The stored log and sync are the guarantee.
Broadcasting presence to every contact. Traffic explodes. Subscribe only to what is visible and debounce.
Putting business logic and state in the gateways. Deploys then drop connections and lose data. Keep gateways thin and state external.
No client backoff or jitter. After an outage every client reconnects at once. Use exponential backoff with jitter.
Claiming end-to-end encryption while the server reads content. Users are misled. Be precise about which parties can read messages.
Key takeaways
Store, then deliver. The durable per-conversation log is the truth, and connections are a fast lane.
Connections size the gateway tier. Memory per connection, not message rate, is the limit.
Order with a sequence number. One owner per conversation assigns seq. Clocks cannot be trusted.
At-least-once plus idempotent receipt. Retries and de-duplication give the effect users expect.
Cumulative cursors beat per-message receipts. They are cheaper and survive loss.
Push is a nudge. The app syncs from its last sequence number when it opens.
Presence is approximate by design. Use heartbeats, TTLs, subscriptions and debouncing.
End-to-end encryption has costs. Search, moderation and recovery change when the server cannot read content.
Practice exercises
Exercise 1 — Starter (30 min): For the illustrative numbers in this lesson, recompute the gateway count if each node holds 50,000 connections instead of 100,000 and you want 30% headroom. Then compute messages per second and yearly storage if users send 80 messages per day instead of 40.
Exercise 2 — Intermediate (2 hours): Write the sync protocol precisely: what a device sends when it reconnects after two hours offline, how the server responds for a one-to-one chat and for a group with 5,000 unread messages across 12 conversations, how gaps are detected, and how you bound the response size. Draw the sequence diagram.
Exercise 3 — Advanced (half day): Design the failover of the sequencer for a conversation partition. Describe how a new owner is chosen, how you prevent two owners from assigning the same seq (consider a fencing token checked by the store), what a client sees during the switchover, and how you test it with fault injection.
Related reading
Message queues - acknowledgements, retries and idempotent consumers that underpin reliable delivery.
Event streaming - per-partition ordering for a conversation log.
Lesson 12 of 14 · Advanced System Design and Case Studies
Design a chat system: WebSocket gateways, presence, per-conversation storage, delivery and acknowledgements, ordering, offline push, group fan-out and encryption.
In the rest of this lesson
High-level design
Reference deployment topology
Deep dive 1: connection gateways and presence
Deep dive 2: ordering, acknowledgements and delivery guarantees
Deep dive 3: offline delivery and group chat
Encryption options (high level)
Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.