Design a Real-Time Chat App
A full-stack favorite: WebSockets and optimistic UI on the client, fan-out and storage on the server.
- 10M socketsconcurrent connections to design for
- ~200 gatewaysat about 50K connections each
- < 200 msdelivery to online users in-region
Show what to say
“Clients hold a WebSocket to any gateway, and Redis pub/sub routes each message to the gateway holding the recipient. Every message gets a per-conversation sequence number, is stored before it's acked, and carries a client id so retries are deduplicated. Offline users sync from their last seq, which also repairs any lost live message.”
Trade-offs
- WebSocket vs long polling: low latency and two-way, but stateful connections to scale.
- Redis pub/sub vs Kafka for routing: simple and fast, but no durability or replay; durability comes from the database plus sync.
- Fan-out on write vs read for groups: fast reads, but write amplification, so large channels fan out on read.
Bring it up before they ask
Never ack before storing: a crash after the ack loses a message the sender thinks was delivered, so store first, then ack, then fan out.
New to the words? Read the one-minute primer
Each phone keeps a open to a gateway, and passes each message to the gateway holding the recipient. Store the message first, then , then deliver. A client that was offline asks for what it missed.
Functional
- 1:1 and group messaging (groups up to 500 members)
- Delivery and read receipts
- Offline delivery with push notifications
- Paginated message history
- Typing indicators and presence
- Media attachments
Non-functional
- Under 200 ms delivery to online users in-region
- Never lose a message once it is acked
- Messages ordered within a conversation
- Multi-device: every device sees every message
- 99.99% availability
Out of scope
- End-to-end encryption (see the follow-ups)
- Voice and video calls
Clarify and estimate
Ask first:
Read more · 153 words
- Groups? 50, 500 or 100K members? Size decides how you .
- Receipts and devices? Delivered and read ticks? Does my phone's message show on my laptop?
- History and encryption? Kept forever? End-to-end encryption moves search to the client.
- Scale? Daily users, and how many are connected at once.
Capacity estimate
| Quantity | Calculation | Result |
|---|---|---|
| Messages | 50M DAU × 40 messages/day = 2B/day | ≈ 23K/s (≈ 70K/s peak) |
| Storage | 2B × ~200 B | ≈ 400 GB/day |
| Connections | 10M concurrent ÷ ~50K per gateway | ≈ 200 gateway nodes |
At roughly 10 KB per idle socket, 10M sockets need about 100 GB of memory across the gateways. The write rate is modest. Open connections shape the design: they last long and hold state, so you scale by adding gateways.
The preset counts each message as one write plus about one history read.
API
events: one socket per device, JSON frames with a type field.
Read more · 108 words
| Direction | Event | Payload |
|---|---|---|
| Client → server | send | { clientId, conversationId, text } |
| Client → server | sync | { conversationId, after } (the last seq the client has) |
| Client → server | read | { conversationId, seq } |
| Client → server | typing | { conversationId } (throttled) |
| Server → client | ack | { clientId, seq } |
| Server → client | message | The stored message, including seq |
| Server → client | receipt | { conversationId, userId, readUpTo } |
| Server → client | typing, presence | Ephemeral, never stored |
REST for the rest:
| Method | Path | Notes |
|---|---|---|
| GET | /conversations | Mine, with unread counts |
| GET | /conversations/:id/messages?before=SEQ&limit=50 | History, newest first |
| POST | /uploads | A presigned upload URL and the object key |
Check who the user is during the HTTP upgrade, so no frame is ever processed for a stranger.
Data model
Messages are append-only and always read by conversation and seq.
Read more · 78 words
At very large scale, Cassandra fits: one partition per conversation, rows clustered by seq.
Short-lived state lives in :
| Key | Type | Purpose |
|---|---|---|
seq:CONVERSATION_ID | counter | INCR gives the next seq |
user:USER_ID | pub/sub channel | Every gateway holding that user's socket subscribes |
presence:USER_ID | string, EX 70 | Exists while heartbeats keep arriving |
// messages: one document per message
{ conversationId: 'c42', seq: 1043, from: 'u_alice', clientId: '7f3c…', text: 'see you at 7', ts: 1760000000000 }
// members: one document per (conversation, user), holding the receipt watermarks
{ conversationId: 'c42', userId: 'u_bob', lastDeliveredSeq: 1043, lastReadSeq: 1040, muted: false }
// conversations
{ _id: 'c42', type: 'group', title: 'Goa trip', memberCount: 12, lastSeq: 1043 }await db.messages.createIndex({ conversationId: 1, seq: -1 }); // history pages and sync
await db.messages.createIndex({ from: 1, clientId: 1 }, { unique: true }); // retries can't duplicate a message
await db.members.createIndex({ conversationId: 1, userId: 1 }, { unique: true }); // { lastReadSeq: 0, … }
await db.members.createIndex({ userId: 1 }); // "my conversations"CREATE TABLE messages (
conversation_id uuid,
seq bigint,
sender_id uuid,
client_id uuid,
body text,
created_at timestamp,
PRIMARY KEY ((conversation_id), seq)
) WITH CLUSTERING ORDER BY (seq DESC);Architecture
Why sockets, not ?
Read more · 158 words
Ask every 5 seconds and a message waits 2.5 seconds on average, and most answers are empty.
| Users | Polling every 5 s | Verdict |
|---|---|---|
| 1,000 | 200 requests/s | Polling is fine |
| 100,000 | 20,000/s, mostly empty | Long polling, or sockets |
| 10M | 2M requests/s | WebSockets |
Gateways just hold sockets and don't know each other. Each listens on a channel per connected user, and the chat service publishes every stored message there. can drop a message, and that is fine: the database is the truth, and sync repairs misses. Play Bob offline and lost → retry.
Drop packets or take Bob offline and watch the ticks.
Tap any box to see what it is and what it talks to.
- Client
- Load balancer
- API gateway
- Pub/Sub
- Service
- Database
- Object storage
- External API
Text description
- Alice sends { clientId, conversationId, text } over her WebSocket.
- The load balancer forwards the frame to gateway 1, which holds Alice's socket.
- The chat service checks Alice is a member, finds no earlier message with this clientId, and runs INCR seq:c42, which returns 1043.
- The message is stored in MongoDB with seq 1043.
- The write is acknowledged. The message is now durable.
- Only now does Alice get her ack, and at the same time the message is published to every member's channel, including user:bob.
- The ack heads back toward Alice while Redis delivers the message to gateway 2, which subscribed to user:bob when Bob's sockets connected there.
- Alice's bubble flips from sending to sent (one tick), and gateway 2 forwards the message toward Bob.
- Gateway 2 writes it to every socket Bob has open, laptop and phone. About 100 ms end to end in one region.
Step-by-step answer
Order: clarify, pick the transport, route messages, persist and , then cover edge cases.
Read more · 579 words
The same message as a sequence:
Clarify
Ask, then name the number that drives everything: 10M open sockets.
Choose the transport
online, push offline, REST for history and uploads. does the routing, so no .
Keep a connection registry on each gateway
userId → Set<socket>, because one user has several devices. Subscribe touser:IDwhile the set is non-empty, and unsubscribe on the last close.let mine = local.get(userId); if (!mine) { local.set(userId, (mine = new Set())); sub.subscribe(`user:${userId}`); } mine.add(ws);Send path: store, then ack, then fan out
Check membership, by
(from, clientId), get aseq, store, ack the sender, then publish. An acked message is never lost, and the unique index turns a retry into a re-ack.const existing = await db.messages.findOne({ from: userId, clientId }); if (existing) return ack(existing.seq); // a retry: just ack again const seq = await pub.incr(`seq:${conversationId}`); await db.messages.insertOne({ conversationId, seq, from: userId, clientId, text, ts: Date.now() }); ack(seq); // durable first, then ack await broadcast(conversationId, { type: 'message', /* … */ });Ordering
Per conversation, by seq. Clients sort and tolerate gaps. Nobody compares order across chats, so there is no global bottleneck.
Offline delivery and sync
On every reconnect, the client sends
syncwith its last seq, and the server replays the rest. That also repairs any missed live message. Members who are offline get a push notification.Receipts as watermarks
Keep a
lastReadSeqandlastDeliveredSeqper member, not a row per message. Reading 1042 implies everything before it.Presence and typing
Presence: a key with a 70 s and a 30 s , so a crashed client expires. Typing: one event per 3 s at most, never stored.
Groups and very large channels
Small groups fan out on write. Big channels fan out on read: members pull on open.
Client: optimistic and resilient
Show the bubble at once as
sending. After reconnect, resend pending messages with the sameclientId. Random (jittered) stops 50K clients returning together.
- Client
- API gateway
- Pub/Sub
- Database
Text description
- Alice → Gateway 1: send { clientId: c1 }
- Gateway 1 → Redis: INCR seq:conv42
- Redis replies to Gateway 1: 1043
- Gateway 1 → MongoDB: insertOne { seq: 1043, clientId: c1, … }
- MongoDB replies to Gateway 1: ok (durable)
- Gateway 1 replies to Alice: ack { seq: 1043 }
- Gateway 1 sends (async) to Redis: PUBLISH user:bob
- Redis sends (async) to Gateway 2: message (user:bob)
- Gateway 2 sends (async) to Bob: message seq 1043
- Bob → Gateway 2: read { seq: 1043 }
- Gateway 2 → MongoDB: lastReadSeq = 1043 if lower
Implementation
Server tabs: gateway and presence. The handlers run inside the gateway for clarity.
Read more · 253 words
At scale they are a service.
Client tabs: the React hook and its tested helpers.
Bugs this fixes: one socket per user, no membership checks, a global seq, and sends that are not .
Build it in the playground. Build this design in the playground and check it against the rubric:
- clients → → two or more gateways;
- gateways connected through ;
- a chat service → database with a ;
- a push service as an external node;
- for media.
Background: Polling, SSE and WebSockets, and Async messaging.
// WebSocket gateway. Any node can hold any user's sockets; Redis pub/sub routes between nodes.
import { WebSocketServer } from 'ws';
import Redis from 'ioredis';
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL); // a subscribed connection can't run other commands
const local = new Map(); // userId -> Set<WebSocket> on this node
const wss = new WebSocketServer({ noServer: true });
// Authenticate during the HTTP upgrade, so no message can arrive before we know who's talking
httpServer.on('upgrade', async (req, socket, head) => {
const userId = await authenticate(req).catch(() => null); // e.g. verify the session cookie
if (!userId) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); return socket.destroy(); }
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req, userId));
});
sub.on('message', (channel, payload) => {
for (const ws of local.get(channel.slice('user:'.length)) ?? []) ws.send(payload);
});
wss.on('connection', (ws, _req, userId) => {
let mine = local.get(userId);
if (!mine) { local.set(userId, (mine = new Set())); sub.subscribe(`user:${userId}`).catch(console.error); }
mine.add(ws);
ws.on('message', raw => {
let m;
try { m = JSON.parse(raw); } catch { return; }
const handler = { send: onSend, sync: onSync, read: onRead }[m.type];
handler?.(ws, userId, m).catch(() => ws.send(JSON.stringify({ type: 'error', clientId: m.clientId })));
});
ws.on('close', () => {
mine.delete(ws);
if (mine.size === 0) { local.delete(userId); sub.unsubscribe(`user:${userId}`).catch(console.error); }
});
});
async function broadcast(conversationId, event) {
const payload = JSON.stringify(event);
await Promise.all((await membersOf(conversationId)).map(m => pub.publish(`user:${m}`, payload)));
}
async function onSend(ws, userId, { clientId, conversationId, text }) {
if (!(await isMember(userId, conversationId))) throw new Error('not a member');
const ack = seq => ws.send(JSON.stringify({ type: 'ack', clientId, seq }));
const existing = await db.messages.findOne({ from: userId, clientId });
if (existing) return ack(existing.seq); // a retry: already stored, just ack again
const seq = await pub.incr(`seq:${conversationId}`); // per-conversation order
const msg = { conversationId, seq, from: userId, clientId, text, ts: Date.now() };
try {
await db.messages.insertOne(msg);
} catch (e) {
if (e.code !== 11000) throw e; // duplicate key: a concurrent retry won the race
return ack((await db.messages.findOne({ from: userId, clientId })).seq);
}
ack(seq); // ack only once the message is durable
await broadcast(conversationId, { type: 'message', ...msg }); // offline members get a push from a separate consumer
}
async function onSync(ws, userId, { conversationId, after = 0 }) {
if (!(await isMember(userId, conversationId))) throw new Error('not a member');
const missed = await db.messages.find({ conversationId, seq: { $gt: after } }).sort({ seq: 1 }).limit(500).toArray();
for (const m of missed) ws.send(JSON.stringify({ type: 'message', ...m }));
}
async function onRead(_ws, userId, { conversationId, seq }) {
const r = await db.members.updateOne(
{ conversationId, userId, lastReadSeq: { $lt: seq } }, // watermarks only move forward
{ $set: { lastReadSeq: seq } });
if (r.modifiedCount) await broadcast(conversationId, { type: 'receipt', conversationId, userId, readUpTo: seq });
}// Client hook: optimistic sends, ack reconciliation, resend after reconnect, gap sync.
// Render the chat with key={conversationId} so state resets when switching conversations.
import { useEffect, useRef, useState } from 'react';
import { reconnectDelay, upsertMessage } from './chat-state.js';
export function useChat(conversationId) {
const [messages, setMessages] = useState([]); // sorted by seq; unsent ones last
const ws = useRef(null), pending = useRef(new Map()), lastSeq = useRef(0);
const upsert = m => setMessages(list => upsertMessage(list, m)); // our own echo, an ack, or a replay
useEffect(() => {
let attempt = 0, stopped = false, timer;
const connect = () => {
const sock = new WebSocket(WS_URL);
ws.current = sock;
sock.onopen = () => {
attempt = 0;
sock.send(JSON.stringify({ type: 'sync', conversationId, after: lastSeq.current })); // fetch the gap
for (const m of pending.current.values()) sock.send(JSON.stringify(m)); // resend unacked
};
sock.onmessage = e => {
const evt = JSON.parse(e.data);
if (evt.type === 'ack') {
pending.current.delete(evt.clientId);
upsert({ clientId: evt.clientId, seq: evt.seq, status: 'sent' });
} else if (evt.type === 'message' && evt.conversationId === conversationId) {
lastSeq.current = Math.max(lastSeq.current, evt.seq);
upsert({ ...evt, status: 'sent' });
}
};
sock.onclose = () => {
if (!stopped) timer = setTimeout(connect, reconnectDelay(attempt++)); // full jitter
};
};
connect();
return () => { stopped = true; clearTimeout(timer); ws.current?.close(); };
}, [conversationId]);
const send = text => {
const msg = { type: 'send', clientId: crypto.randomUUID(), conversationId, text };
pending.current.set(msg.clientId, msg);
upsert({ ...msg, from: 'me', status: 'sending' }); // optimistic bubble
if (ws.current?.readyState === WebSocket.OPEN) ws.current.send(JSON.stringify(msg));
};
return { messages, send };
}Failure modes and trade-offs
Read more · 139 words
| What breaks | Effect | Mitigation |
|---|---|---|
| A gateway dies | Its ~50K sockets drop | Jittered reconnect; sync from the last seq |
| A pub/sub message is lost | A live message is missed | Sync on reconnect and on open |
| Retry after a lost ack | Duplicate message | Unique (from, clientId); re-ack |
| Out-of-order arrival | Wrong display order | Clients sort by seq |
| A huge group | Fan-out storm | Fan out on read |
| Redis is down | No routing, no new seqs | Replicated Redis; sync recovers |
| Mass reconnect after a deploy | Thundering herd | Drain slowly; client jitter |
Trade-offs
| Choice | Gain | Cost |
|---|---|---|
| WebSocket, not long polling | Fast, two-way | Stateful connections |
| Redis pub/sub, not Kafka | Simple and fast | No replay |
| Fan-out on write | Fast reads | Heavy writes for big groups |
| MongoDB, not Cassandra | Simpler | Less write scale |
Follow-up questions
How do you guarantee message ordering?
Per conversation only, from a counter that clients sort by. Gaps are fine, and there is no global bottleneck.
How would you build presence and typing at scale?
Presence is a key with a , refreshed by . Typing is throttled and never stored.
How does end-to-end encryption change the design?
The server sees only ciphertext. Search, previews and moderation move to the client, and each message is encrypted for every device of every member.
What about very large channels (100K members)?
on read, with a view count instead of per-member receipts.
How do media messages work?
Upload to with a presigned URL, then send the object key as a message.
How do you run 10M concurrent connections?
About 200 gateways at ~50K sockets each, behind layer-4 . Deploy by draining sockets gradually.
How does multi-device sync work?
Every device's socket is in the user's set, so each gets every message.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.