Index/Chat / Slack

SponsorGitHub
Design12 min

Chat / Slack

Fifty million concurrent sockets, ordered message delivery, and users who go offline mid-conversation.

Primary concepts and the hard part

Concepts
WebSocketsconnection registrypub/sub routingmessage orderingoffline deliverywide-column storagefan-out (small)push notifications
The hard part they’re probing
You have 50 stateful gateway nodes and a message for Alice. How does the sender's request find the node holding Alice's socket? And what happens when Alice is offline?

Requirements

Functionalwhat it must do
1:1 and group messaging
Real-time delivery to online users
Offline users receive messages on reconnect
Delivery receipts: sent → delivered → read
Online/presence status
Message history with pagination
Non-functionalhow well it must do it
Delivery latency p99 < 500ms for online users
Messages must never be lost (durable before ack)
Consistent ordering within a conversation
Availability over global consistency
Out of scopedeliberately left out
Voice/video calls
E2E encryption key exchange (mention it exists)
File transfer specifics
Search
Scale
500M DAU · 50M concurrent connections
Messages: 100B/day → ~1.2M/sec avg, 4M/sec peak
Msg row ~200B → 20 TB/day
Connections per gateway node: ~50k → need ~1,000 gateway nodes
Conclusion
50M concurrent sockets means the gateway tier is its own scaling problem, separate from storage. That's why it's drawn as a distinct tier.

API / Model

WebSocketauth via token in handshake
WS
wss://chat.example.com/connect
→ client sends: {type:"send", client_msg_id, conv_id, body}
← server sends: {type:"message"|"receipt"|"presence", ...}
101Switching Protocols
RESThistory and setup
GET
/v1/conversations?cursor=
200OKconversation list
GET
/v1/conversations/{id}/messages?cursor=&limit=50
200OKmessage page
POST
/v1/conversations
{member_ids[]}
201Createdconversation_id
POST
/v1/conversations/{id}/read
{up_to_msg_id}
204No Content
primary or partition keysort key, ↓ newest firstforeign key → referenced columnHover a table or column to trace its keys

High-level architecture

DatabaseCacheQueue / streamExternal systemFocusClick a node for details

The design puts a stateless Chat Service between two stateful gateways: one holds the sender's socket and one holds the recipient's. A Redis connection registry links them, and every message is stored before anyone is told about it.

  1. Client A sends the message over WSS to WS Gateway #17, which holds A's socket and forwards it to the Chat Service. The Chat Service validates and authorizes it, assigns a Snowflake message_id, dedupes on client_msg_id, and writes it to the messages store, where it lands in the conversation_id partition.
  2. Only after that write succeeds does the Chat Service ack the sender.
  3. It then resolves the recipient in the connection registry, which maps user_id to the gateway node holding that user's socket and expires stale entries through TTL and heartbeats.
  4. If the recipient is online, the Chat Service publishes to that node's pub/sub channel, gw:42, so only WS Gateway #42 receives the message.
  5. WS Gateway #42 pushes it to Client B over B's socket.

When the recipient is offline, the message is written to inbox_queue as an undelivered row instead, and APNs / FCM sends a mobile push. When B reconnects, the gateway drains that backlog to the client, B acks, and the rows are deleted.


Trade-offs and deep dives

01
Ordering

Message order is defined by the server-assigned Snowflake ID, not by client timestamps (clocks lie, networks reorder). Because all messages in a conversation share a partition key, they are stored in one ordered log. Clients sort by message_id, which is monotonic per conversation. Ordering across conversations doesn't matter and isn't guaranteed.

02
Durability before ack

The sender gets "sent" only after the message is committed to storage. If you ack on receipt at the gateway and the gateway dies, the message vanishes with a checkmark showing. Never ack before durable.

03
Idempotency

Client generates client_msg_id (a UUID). Retries on flaky mobile networks reuse it. Server dedupes, so a retry returns the original message_id rather than creating a duplicate. This is the single most important detail for mobile chat.

04
Why a connection registry and not "just broadcast to all gateways"

Broadcasting every message to 1,000 gateways is 1,000x write amplification on the pub/sub layer. The registry turns a broadcast into a targeted publish. Cost: an extra Redis lookup and a consistency window when a user reconnects to a different node (handled by TTL + the fact that a stale publish simply finds no socket and falls through to the offline path).

05
Gateway nodes are stateful — accept it, then contain it

This violates the stateless ideal from Module 1. Contain it by keeping only the socket on the node; all durable state lives elsewhere. A gateway dying just means clients reconnect and re-register.

06
Group chat fan-out

Slack channels with 50,000 members are the celebrity problem again. For small groups, push to each member. For very large channels, don't push to everyone — clients that have the channel open subscribe to a channel-level pub/sub topic, and everyone else pulls on open. Same hybrid shape as the feed.

07
Presence is expensive

Naive presence (broadcast every status change to everyone who might care) is a fan-out explosion. Mitigations: only compute presence for conversations the user currently has open, batch updates on a few-second interval, and treat presence as best-effort/lossy. Say out loud that presence is the most over-engineered part of most chat designs.

08
Read receipts

Store last_read_msg_id per (user, conversation) rather than a per-message read flag. One row updated instead of N. Unread count = count of messages with id > last_read_msg_id, which is a bounded range scan, or a cached counter.

09
Storage partitioning risk

A busy channel is a hot partition. Bound partition size by bucketing the key: PK = (conversation_id, time_bucket). Reads for recent history hit the newest bucket.

10
E2E encryption

If required, the server stores ciphertext only and cannot do server-side search, previews, or moderation. Say this trade explicitly — it removes features, not just adds security.


Possible follow-up questions

6 questions·try answering before you reveal