Design a Chat System
Persistent connections, per-channel ordering, read cursors and offline fan-out: what a chat system is actually made of.
HELLO(heartbeat_interval) -> IDENTIFY -> READY(session_id, resume_gateway_url) -> DISPATCH(seq) ... RESUME(token, session_id, seq)A chat system is a fan-out problem wearing a messaging UI. Storing messages is the easy half. The hard parts are holding millions of mostly idle connections open, agreeing on what order two people's messages happened in, and delivering to a device that is asleep.
Connections
Clients hold a WebSocket to a stateful gateway tier kept separate from the stateless HTTP API, because a connection is state and a deploy destroys it. Discord's gateway makes recovery explicit: Hello (op 10) carries heartbeat_interval, the client heartbeats (op 1) with the last sequence number it received, and after a drop it sends Resume (op 6) with token, session_id and seq to the resume_gateway_url from READY. The server then replays only the events after seq.
Ordering and receipts
There is no global order and you do not need one. Assign per-channel monotonic, time-sortable ids on the server and have clients sort by id rather than by arrival; the sender renders optimistically against a client-generated nonce, so the echo deduplicates against it. Read state is a cursor per (user, channel) — one last_read_message_id — not a row per message per member.
Offline and groups
Fan-out resolves members to live sessions, groups the sends by gateway node, and queues a push (APNs or FCM) for everyone with no session. Presence is the expensive signal: it changes far more often than messages and fans out to everyone who can see you, so it lives in memory, not in the durable store.
What trips people up
Treating the socket as the delivery guarantee. The durable channel is the source of truth; the socket is a fast path a reconnect can always rebuild from.
Example
// Chat gateway client: one WebSocket per device, resumable across drops.
// Message order comes from the server-assigned id, never from arrival order.
const GATEWAY = 'wss://gateway.example.com/?v=1&encoding=json';
const OP = {
DISPATCH: 0, HEARTBEAT: 1, IDENTIFY: 2, RESUME: 6,
RECONNECT: 7, INVALID_SESSION: 9, HELLO: 10, HEARTBEAT_ACK: 11,
};
// Survives a reconnect; this is the only state worth keeping on the client.
let session = { id: null, seq: null, resumeUrl: null };
let ws, heartbeat, attempt = 0;
function send(socket, op, d) {
const frame = { op, d }; // ES2015 property shorthand
// const frame = { op: op, d: d }; // the longhand it desugars to - same object
socket.send(JSON.stringify(frame));
}
// Full jitter: pick uniformly from zero up to the capped backoff. Without the
// random factor every dropped client reconnects together.
const backoff = () => Math.min(30_000, 1_000 * 2 ** attempt++) * Math.random();
function connect(token) {
// After a READY we reconnect to resume_gateway_url, not to the front door.
ws = new WebSocket(session.resumeUrl ?? GATEWAY);
ws.onmessage = (ev) => {
const payload = JSON.parse(ev.data);
if (payload.s != null) session.seq = payload.s; // last sequence number seen
switch (payload.op) {
case OP.HELLO:
heartbeat = setInterval(
() => send(ws, OP.HEARTBEAT, session.seq), // heartbeat carries the seq
payload.d.heartbeat_interval, // milliseconds, server-chosen
);
if (session.id) send(ws, OP.RESUME, { token, session_id: session.id, seq: session.seq });
else send(ws, OP.IDENTIFY, { token, properties: { os: 'web' } });
break;
case OP.DISPATCH:
attempt = 0;
if (payload.t === 'READY') {
session.id = payload.d.session_id;
session.resumeUrl = payload.d.resume_gateway_url;
}
if (payload.t === 'MESSAGE_CREATE') onMessage(payload.d);
break;
case OP.INVALID_SESSION:
// The buffer we wanted to resume from is gone: start clean and refetch.
session = { id: null, seq: null, resumeUrl: null };
break;
}
};
ws.onclose = () => { clearInterval(heartbeat); setTimeout(() => connect(token), backoff()); };
}
// Ids are time-ordered, so sorting by id yields the channel's order even when
// frames arrive out of order after a resume or across two sockets.
const buffer = [];
function onMessage(msg) {
// Our own optimistic render carried the nonce; the echo must not duplicate it.
if (buffer.some((m) => m.id === msg.id || (msg.nonce && m.nonce === msg.nonce))) return;
const at = buffer.findIndex((m) => BigInt(m.id) > BigInt(msg.id));
buffer.splice(at === -1 ? buffer.length : at, 0, msg);
}
connect(await getToken());When to use it
- Choosing a sticky WebSocket tier over long polling for a product that must survive rolling deploys.
- Deciding what the client does after a 30-second tunnel: resume from a sequence number, or refetch the channel.
- Modelling read receipts for a 10,000-member group without writing 10,000 rows per message.
- Working out why presence traffic, not message traffic, is what limits the gateway tier.
More examples
Read receipts as a cursor, not a row per message
Shows the write-amplification decision that defines a chat schema. A delivery or read row per recipient per message multiplies every send by the member count, which is fine for a two-person DM and unservable for a large group; a cursor keeps the write cost at one row per reader per channel. Reach for it as soon as group size is unbounded, and keep per-message receipts only where the product genuinely shows who read what, which is normally small groups.
-- One row per (user, channel). A 10,000-member channel does not get 10,000
-- read rows per message - it gets 10,000 rows in total, updated in place.
CREATE TABLE read_state (
user_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
last_read_message_id BIGINT NOT NULL,
mention_count INT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel_id)
);
-- Unread count is a bounded range count above the cursor, capped so a channel
-- nobody has opened for a year does not scan a year of messages.
SELECT count(*) FROM (
SELECT 1 FROM messages
WHERE channel_id = $1
AND id > (SELECT last_read_message_id FROM read_state
WHERE user_id = $2 AND channel_id = $1)
LIMIT 100
) capped;
-- Marking read is one idempotent upsert that never moves backwards, so a
-- retrying or out-of-order client cannot un-read a channel.
INSERT INTO read_state (user_id, channel_id, last_read_message_id)
VALUES ($2, $1, $3)
ON CONFLICT (user_id, channel_id) DO UPDATE
SET last_read_message_id = GREATEST(read_state.last_read_message_id, excluded.last_read_message_id);Fan-out grouped by gateway node, with a push fallback
The routing table (user to live sessions) is the piece people forget to design: it changes on every connect and disconnect, is worthless after a restart, and is read once per message per channel. Reach for this grouping the first time a channel has thousands of members, and note the two different durability stories - sockets are disposable, the push queue is not.
// Called after the message is durably written. Delivery here is best-effort:
// the stored channel is what a client reads on reconnect, so a lost frame is
// a slower render, not a lost message.
async function fanout(channelId, message) {
const memberIds = await members.list(channelId); // page this for huge channels
const routes = await sessions.lookup(memberIds); // Map<userId, [{node, sessionId}]>
const byNode = new Map();
const offline = [];
for (const userId of memberIds) {
const live = routes.get(userId) ?? [];
if (live.length === 0) { offline.push(userId); continue; }
for (const s of live) {
if (!byNode.has(s.node)) byNode.set(s.node, []);
byNode.get(s.node).push(s.sessionId);
}
}
// One RPC per gateway node, not per session: a 50,000-member channel becomes
// tens of sends instead of 50,000. allSettled because one dead node must not
// stop delivery to the others.
await Promise.allSettled(
[...byNode].map(([node, sessionIds]) =>
gatewayRpc(node, 'DISPATCH', { sessionIds, t: 'MESSAGE_CREATE', d: message })),
);
// Push is a separate queue with its own retries, rate limits and provider
// failures - it must never be in line with the socket delivery.
if (offline.length) {
await pushQueue.enqueue(offline.map((userId) => ({
userId, channelId, messageId: message.id, dedupeKey: `${message.id}:${userId}`,
})));
}
}Presence without a write per heartbeat
Shows why presence, not messaging, is usually the first thing to fall over, and the two mitigations that matter: expiry instead of explicit offline bookkeeping, and broadcasting transitions instead of heartbeats. Reach for it when the gateway tier's CPU is spent on presence frames, and pair it with on-demand member loading so a large channel does not subscribe every viewer to every member's status.
// Presence is the highest-volume signal in a chat product: it changes on every
// tab focus and every network blip, and it fans out to everyone who can see you.
// So it is memory-only, expiring, and never written to the message store.
const PRESENCE_TTL = 45; // seconds; longer than the client heartbeat interval
async function onHeartbeat(userId, sessionId, status) {
// A hash per user holds one field per device, and the whole key expires on its
// own - so a user whose last device hard-crashes goes offline with nobody
// cleaning up after it.
await redis.hSet(`presence:${userId}`, sessionId, status);
await redis.expire(`presence:${userId}`, PRESENCE_TTL);
// Fan out only on a transition, not on every heartbeat. Without this check,
// one user with five devices generates a broadcast every few seconds.
const effective = status === 'online' ? 'online' : await rollUp(userId);
if (effective !== lastBroadcast.get(userId)) {
lastBroadcast.set(userId, effective);
await fanoutPresence(userId, effective); // to that user's visible peers only
}
}
Discussion