Loading…
Loading…
Design a scalable real-time chat system with WebSockets, message delivery guarantees, read receipts, group chats, and offline message synchronization strategies.
Tags
This post applies concepts from the System Design from Zero to Hero series.
A real-time chat system uses persistent WebSocket connections for instant messaging, a message queue for delivery guarantees, and sequence IDs for ordering and offline sync. The architecture centers around a connection gateway that manages millions of WebSocket sessions, a chat service that handles message routing and persistence, and a presence service that tracks online/offline status. Group chat introduces fan-out challenges that require careful trade-offs between write amplification and read latency.
Assume 500 million daily active users, each sending an average of 40 messages per day:
Clients ↔ WebSocket Gateway Cluster ↔ Chat Service ↔ Message Store
↕ ↕
Connection Registry Message Queue (Kafka)
↕ ↕
Presence Service Notification Service
Message send flow:
WebSockets provide full-duplex communication, which is essential for chat. Unlike HTTP polling or Server-Sent Events, WebSockets let both client and server push data at any time over a single persistent TCP connection. For more on connection management at scale, see Part 3: Load Balancing.
Connection Gateway Architecture:
Each gateway server manages tens of thousands of WebSocket connections. When a user connects:
user_id → gateway_server_id in a distributed Connection Registry (Redis cluster).Load balancing considerations: Use Layer 4 (TCP) load balancing for WebSocket connections, not Layer 7 (HTTP). Layer 7 load balancers can interfere with the WebSocket upgrade handshake. Sticky sessions are not required because the Connection Registry decouples message routing from connection assignment.
Message ordering is deceptively difficult in a distributed system. Wall-clock timestamps are unreliable across servers and devices. Instead, use per-conversation monotonically increasing sequence numbers.
How it works:
last_sequence_id counter.last_seen_sequence_id and the server returns all messages with a higher sequence number.# Atomic sequence assignment (pseudocode)
def send_message(conversation_id: str, sender_id: str, content: str):
# Atomic increment — use Redis INCR or database row-level lock
seq_id = redis.incr(f"conv:{conversation_id}:seq")
message = {
"id": generate_uuid(),
"conversation_id": conversation_id,
"sender_id": sender_id,
"content": content,
"sequence_id": seq_id,
"timestamp": now(),
"status": "SENT"
}
message_store.insert(message)
route_to_recipients(conversation_id, message)
return messageFor high-throughput conversations (busy group chats), a single Redis key per conversation becomes a bottleneck. In that case, use a database-level auto-increment within a partitioned message table, where each conversation maps to a single partition.
The three-tick system (sent → delivered → read) requires tracking message status transitions:
Sender → Server: "Here is a message"
Server → Sender: "ACK: message stored" (✓ sent)
Server → Recipient: "New message for you"
Recipient → Server: "ACK: received" (✓✓ delivered)
Recipient → Server: "Read up to seq_id 42" (✓✓ read, blue ticks)
Server → Sender: "Recipient read up to seq_id 42"
Optimization for read receipts: Instead of sending a read receipt per message, the client sends "I have read all messages up to sequence_id X in conversation Y." This single event marks all prior messages as read, reducing network traffic dramatically.
Tracking presence for hundreds of millions of users is expensive if done naively. A heartbeat-based approach works well:
online in a Redis hash with a TTL of 60 seconds.offline. Their last_seen timestamp is recorded.Fan-out of presence updates: Do not broadcast presence changes to all contacts. That creates a thundering herd problem when popular users go online/offline. Instead, use a pull model: when User A opens a chat with User B, User A's client queries User B's presence. For the friend list screen, batch-fetch presence for visible contacts only.
For the pub/sub mechanisms that power real-time presence subscriptions, see Part 6: Message Queues and Event-Driven Architecture.
Group chat introduces a fan-out problem: when a user sends a message to a group of 200 members, how do you deliver it to all 200?
Fan-out on write:
When a message arrives, the server writes a copy to each recipient's inbox. This pre-computes delivery, making reads fast (each user just reads their own inbox). The downside is write amplification: a single message to a 200-member group creates 200 writes.
Fan-out on read:
The message is stored once in the group's message table. When a user opens the group chat, they query the group's messages directly. Reads are slightly slower (fetching from a shared table), but writes are minimal.
Hybrid approach (recommended):
Chat history follows a clear access pattern: recent messages are accessed frequently, while older messages are rarely read. This is a textbook case for hot/cold storage tiering.
conversation_id. This is the primary read path.A background job periodically migrates messages from hot to warm to cold storage based on age.
In end-to-end encryption (E2E), the server never sees plaintext messages. The basic flow:
Group E2E encryption is more complex. The Signal Protocol uses a "sender key" approach where each group member generates a sender key, distributes it to all other members, and encrypts group messages with that key. Key rotation happens when members leave the group.
Note that E2E encryption means the server cannot perform server-side search on message content, spam filtering on encrypted messages, or link previews. These features must happen on the client device.
| Column | Type | Description |
|---|---|---|
| conversation_id | UUID | Partition key |
| sequence_id | BIGINT | Clustering key (ascending) |
| message_id | UUID | Globally unique message ID |
| sender_id | UUID | User who sent the message |
| content | TEXT | Message body (encrypted if E2E) |
| message_type | VARCHAR | text / image / video / document |
| status | VARCHAR | sent / delivered / read |
| created_at | TIMESTAMP | Server-side timestamp |
| Column | Type | Description |
|---|---|---|
| conversation_id | UUID | Primary key |
| type | VARCHAR | one_on_one / group |
| name | VARCHAR | Group name (null for 1:1) |
| created_by | UUID | User who created the conversation |
| created_at | TIMESTAMP | Creation time |
| last_sequence_id | BIGINT | Latest sequence number |
| last_message_at | TIMESTAMP | Used for sorting conversation list |
| Column | Type | Description |
|---|---|---|
| user_id | UUID | Partition key |
| conversation_id | UUID | Clustering key |
| last_read_seq_id | BIGINT | Last sequence ID the user has read |
| unread_count | INT | Number of unread messages |
| muted_until | TIMESTAMP | Mute expiration |
Key: presence:{user_id}
Fields:
status: "online" | "offline"
last_seen: timestamp
TTL: 60 seconds (auto-expire to offline)
WebSocket gateway scaling: Add more gateway servers behind a TCP load balancer. Each server handles ~50K connections. The Connection Registry (Redis) decouples message routing from server assignment, so any gateway can be added or removed without disrupting other connections.
Chat Service scaling: The Chat Service is stateless. Scale horizontally behind an internal load balancer. The only coordination point is the sequence number counter, which is partitioned per conversation.
Message Store scaling: Cassandra scales linearly by adding nodes. Partition by conversation_id so all messages in a conversation live on the same node, enabling efficient range queries (fetching the last 50 messages).
Cross-region deployment: For a global user base, deploy gateway servers in multiple regions. Use a geo-DNS service to route users to the nearest region. Messages between users in different regions are routed through an inter-region message bus.
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Transport | WebSocket | Long polling | WebSocket for full-duplex real-time |
| Ordering | Timestamps | Sequence IDs | Sequence IDs for guaranteed ordering |
| Group fan-out | Fan-out on write | Fan-out on read | Hybrid based on group size |
| Message store | Cassandra | DynamoDB | Cassandra for its excellent partition-based range queries |
| Presence | Push model | Pull model | Pull to avoid thundering herd |
| Encryption | Server-side | End-to-end | E2E for privacy, server-side if search needed |
Why not HTTP long polling? Long polling requires the client to repeatedly open new HTTP connections. Each connection consumes a file descriptor, adds TLS handshake overhead, and introduces latency between the poll timeout and the next request. WebSockets maintain a single persistent connection with sub-millisecond delivery latency.
Why Cassandra over a relational database? Chat messages have a write-heavy append pattern and read access is almost exclusively by conversation (partition key). Cassandra's log-structured storage engine and partition-based data model are a natural fit. A relational database would require sharding to achieve the same write throughput, adding operational complexity.
Each server handles tens of thousands of connections. A connection gateway layer routes messages between users on different servers, often using an in-memory pub/sub system like Redis to locate which server holds each user's connection. The gateway servers are stateless in the sense that they hold only transient connection state. The Connection Registry (a Redis cluster) maintains the mapping of user IDs to gateway servers, enabling any service to find where a user is connected and route a message to the correct gateway.
Messages are stored persistently on the server with delivery status tracking. When the recipient comes online, pending messages are pushed in order using sequence numbers. The client acknowledges receipt to update delivery status. The key mechanism is the per-conversation sequence number: the client reports its last_seen_sequence_id upon reconnection, and the server sends all messages with higher sequence numbers. This ensures no messages are missed even if the user was offline for days.
WebSockets are better for chat because they provide full-duplex communication, allowing both sending and receiving on one connection. SSE is unidirectional and would require a separate channel for sending messages. With SSE, the client would need to use regular HTTP POST requests to send messages while receiving them through the SSE stream. This creates two separate connections and adds complexity to the protocol. WebSockets give you a single bidirectional channel that maps naturally to the chat use case.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Design a fault-tolerant e-commerce order system with inventory management, payment processing, saga pattern for transactions, and event-driven order fulfillment.
Build observable systems with structured logging, distributed tracing, and metrics dashboards. Learn SRE practices including SLOs, error budgets, and incident response.
Understand the CAP theorem, its practical implications, and distributed consensus algorithms like Raft and Paxos. Learn how real databases handle partition tolerance.