InterviewPrepKit

Home / Cheat Sheet / System Design

Cheat sheet

How to design a chat system

Read the full lesson →

Chat is a routing problem wearing a storage costume: messages are small and ordinary, but the destination is one socket on one machine that can die.

The one property

  • A persistent connection makes the serving tier stateful: the server keeps per-user state, so the box holding your socket (one open two-way connection) is not interchangeable.
  • Every hard thing descends from this: locating a user’s socket, ordering across writers, cheap presence, and a delivery guarantee that cannot be exactly-once.
  • Presence = the online/offline/last-seen dot. Fanout = getting one message out to many recipients.

The three decisions

DecisionChoiceWhy
TransportWebSocketPersistent push channel; makes tier stateful
Store once or per-recipientOnce per conversationPer-recipient = 7.9x bytes, 86.5 PB
What orders messagesPer-conversation seq counterWall-clock skew inverts order

Back-of-envelope

  • Scale: 500 M DAU, 4% concurrent = 20 M sockets; 40 msgs/user/day = 20 B/day = 231 k msgs/s avg.
  • Fanout: 0.7 x 1 + 0.3 x 24 = 7.9 recipients/msg -> 1.83 M deliveries/s (the number that sizes registry, receipts, fanout).
  • Storage: 100 B/row, 2 TB/day, x3 replicas x5 yr = 11 PB. Chat is not storage-bound.

Connection tier: 100 boxes, not 3

  • Four resources each floor the box count; the largest wins (all must hold at once):
ResourceFloorNote
Memory~3 boxes10 KB/socket tuned; footprint spans ~14x, a config decision
File descriptors~20 boxesfs.nr_open ~1.05 M per process
Proxy port exhaustion306 tuples65,535 source ports per (proxy IP, backend IP, port)
Blast radius100 boxes200 k sockets/box; a death orphans 200 k clients
  • Death is bounded by the session registry, not TLS handshake (~1.5 ms, cheap).
  • Reconnect jitter window = blast radius / op budget = 200 k / 10 k ops/s = 20 s. Jitter = each client waits a random delay before retrying.

Ordering: per-conversation seq

  • Server timestamps fail: clock-diff SD = 10 x sqrt(2) = 14.14 ms; a 50 ms gap is 3.5 SD -> ~1 in 4,900 inversions -> ~203,000 inverted pairs/day. Snowflake ids just re-encode the same skew.
  • Fix: one monotonic counter per conversation, assigned by the single owner shard (seq = last_seq + 1).
  • Not a bottleneck: mean conversation gets ~1 msg / 8 hours (0.000037/s); contention bounded by one conversation, never the platform.
seq buysvs
Total orderTimestamps + tiebreak (disagree across gateways)
Gap detection (holds 41+43, knows 42 missing)Hope / full resync
Idempotency: (conv_id, seq) uniqueNothing client-side
O(1) cursors: unread = last_seq - last_read_seqA set of msg-ids per user

Groups: the 458 threshold

  • Storage fanout: never per-recipient. Socket fanout: always, but strategy flips.
  • Direct routing costs M sends; broadcast costs G gateways, where G = N x (1 - (1 - 1/N)^M), capped at N = 100.
  • Threshold where (1 - 1/100)^M = 0.01: M = ln(0.01)/ln(0.99) = 458. Below = direct; above = broadcast. Set by fleet size (halve fleet -> 228).
  • Above threshold also: suppress receipts, suppress presence, membership becomes the topic (a “group” and a “channel” are different systems).

Delivery: at-least-once + dedup

  • Exactly-once is unachievable (two-generals problem): a lost ack is indistinguishable from a lost message, so the sender must retry.
  • At-least-once retries until acked (never lost, may duplicate); receiver dedups on (conv_id, seq). “Exactly-once” = this, honestly named.
  • Volume: ~0.3% ack-loss x 1.58e11 deliveries = ~474 M duplicates/day. Dedup is a mainline path.
  • Two dedup points: sender keyed on client_msg_id (a client-generated ULID/UUIDv7 idempotency key, ~1.67 GB Redis, 300 s window); receiver keeps last 1,000 seq/conversation (~800 KB on a phone).
  • Receipts are cursors, not events: one read per open carrying highest seq -> 3.66 M/s down to 87 k/s (42x).
  • Offline queue is a query, not a store: range scan from last_delivered_seq (a real store would be 1.05 TB); offline users just get a content-free push wake-up.

Presence: heartbeat = 180 s

  • Heartbeat cost = connections / interval = 20 M / h. Detect offline after 2 missed beats; mean staleness = 1.5h.
  • h bounded by battery (radio tail ~10 s -> duty cycle 10/h) below and carrier NAT timeout (~300 s, else silent half-open) above. Window ~120-240 s; 180 s in the middle.
  • The green dot comes from activity (any frame refreshes presence), not heartbeats.
  • Fanout fix subscribe-on-view: send transitions only to the ~1.5 watchers with the conversation open -> 1.16 M/s down to 87 k/s (13x); token-bucket per user to tame flapping.

Gotchas

  • The two routing diamonds ask unrelated questions: “how to route” (member_count vs 458) vs “is this recipient reachable now” (socket open?). Conflating them is the classic drawing error.
  • Session registry locates sockets (where a user is); consistent hashing owns conversations (where they should be). Different questions.
  • The message store is the only authoritative copy: every failure degrades to “late”, not “lost” — one log + one cursor per client turns every failure into a reconnect.
Want the full picture? The lesson has the derivations, worked examples, and diagrams this card compresses into bullets. Read the full lesson →
Report a bug