{"id":"design-chat-system","title":"Design a chat system","sections":[{"id":"overview","title":"Overview","html":"<p>A chat system lets people exchange short messages in near real time, one to one or in groups, across several devices each. It looks simple until you list what users take for granted: messages show up in the same order for everyone, nothing is lost when a phone goes through a tunnel, the laptop knows what you read on your phone, and the little \"delivered\" and \"read\" marks are accurate.</p>\n<p>This question comes up often because it touches long-lived connections, ordering, storage design for an append-heavy workload, and fan-out. The primer's <a href=\"/learn/ood-online-chat\">object-oriented chat server</a> covers the class structure. This chapter is about the distributed system underneath. It assumes you've read <a href=\"/learn/realtime\">real-time communication</a> and <a href=\"/learn/unique-ids\">unique ID generation</a>.</p>","headings":[]},{"id":"requirements-and-estimates","title":"Requirements and estimates","html":"<h3 id=\"requirements-and-estimates\">Requirements and estimates</h3>\n<p>Start by pinning down scope with the interviewer. A reasonable set:</p>\n<ul>\n<li><strong>Functional:</strong> 1:1 chats and group chats; text messages (attachments go to object storage and are referenced by URL); online/last-seen presence; sent, delivered and read receipts; history on every device a user signs into; push notifications for offline users.</li>\n<li><strong>Non-functional:</strong> a message sent to an online recipient arrives within a few hundred milliseconds; no message is lost once the sender sees \"sent\"; every member sees a conversation in the same order; high availability (a chat outage is very visible).</li>\n<li><strong>Out of scope for now:</strong> voice and video calls, search, bots.</li>\n</ul>\n<p>Then size it. These numbers are assumptions for a large consumer app, not figures from any real product:</p>\n<ul>\n<li>200 million daily active users, each sending 40 messages a day.</li>\n<li>Messages: 200M × 40 = 8 billion a day. 8 × 10⁹ ÷ 86,400 s ≈ 92,600 a second on average. With a peak of about 3× average, plan for roughly <strong>300,000 writes a second</strong>.</li>\n<li>Storage: assume 200 bytes per message including metadata. 8 × 10⁹ × 200 B = 1.6 TB a day, or 1.6 TB × 365 ≈ 584 TB a year. With 3 replicas that's about <strong>1.75 PB a year</strong>.</li>\n<li>Connections: if 25% of daily users are online at the busiest moment, that's 50 million open connections. If one connection server holds 250,000 of them (a conservative assumption that leaves headroom for memory and CPU), you need 50M ÷ 250K = <strong>200 connection servers</strong>, plus spares.</li>\n<li>Deliveries: one message goes to every other member's devices. If an average message reaches 2.5 devices, deliveries are 2.5× the message rate: about 750,000 a second at peak.</li>\n</ul>\n<p>Two things stand out. The write rate is high and append-only, which shapes storage. And the connection count, not the message rate, sizes the front tier. See <a href=\"/learn/estimation\">estimation</a> for more on this kind of arithmetic.</p>","headings":[{"id":"requirements-and-estimates","title":"Requirements and estimates","depth":3}]},{"id":"api-and-data-model","title":"API and data model","html":"<h3 id=\"api-and-data-model\">API and data model</h3>\n<p>Clients talk to the system over two channels.</p>\n<p>A <strong>WebSocket</strong> to a connection server carries everything real time, in both directions. A small set of frame types is enough:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>client -> server  send      {conv_id, client_msg_id, body}</span></span>\n<span class=\"line\"><span>server -> client  ack       {client_msg_id, msg_id, seq}</span></span>\n<span class=\"line\"><span>server -> client  message   {conv_id, msg_id, seq, sender_id, body, sent_at}</span></span>\n<span class=\"line\"><span>client -> server  delivered {conv_id, up_to_seq}</span></span>\n<span class=\"line\"><span>client -> server  read      {conv_id, up_to_seq}</span></span>\n<span class=\"line\"><span>client -> server  sync      {cursor}</span></span></code></pre>\n<p><code>client_msg_id</code> is a random ID the client picks before sending. If the connection drops before the ack arrives, the client resends with the same ID, and the server uses it to drop the duplicate. That gives you at-least-once delivery on the wire with exactly-once storage.</p>\n<p>Plain <strong>HTTPS</strong> handles everything that isn't latency sensitive: creating groups, changing membership, uploading attachments, and fetching older history page by page.</p>\n<p>The core tables:</p>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th>Table</th><th>Key</th><th>Holds</th></tr></thead><tbody><tr><td><code>messages</code></td><td>partition: <code>(conv_id, bucket)</code>; sort: <code>seq</code> descending</td><td>sender, body, <code>msg_id</code>, timestamp</td></tr><tr><td><code>conversations</code></td><td><code>conv_id</code></td><td>type (1:1 or group), member list, <code>last_seq</code></td></tr><tr><td><code>user_conversations</code></td><td>partition: <code>user_id</code>; sort: <code>updated_at</code></td><td>per-user view: <code>last_read_seq</code>, muted, preview</td></tr><tr><td><code>devices</code></td><td><code>user_id</code>, <code>device_id</code></td><td>push token, sync cursor</td></tr></tbody></table></div>\n<p>Unread counts fall out for free: <code>last_seq − last_read_seq</code>. You never count rows.</p>","headings":[{"id":"api-and-data-model","title":"API and data model","depth":3}]},{"id":"high-level-design","title":"High-level design","html":"<h3 id=\"high-level-design\">High-level design</h3>\n<figure class=\"diagram\" data-text=\"Sender <-> Connection server A (WebSocket); Connection server A -> Chat service; Chat service -> Message store (append); Chat service -> Session registry (lookup); Chat service -> Connection server B; Connection server B -> Recipient; Chat service -> Push service (offline); Push service -> APNs / FCM\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 -13 720 283\" width=\"720\" role=\"img\" aria-label=\"Sending a message to an online and an offline recipient\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"153\" y1=\"42\" x2=\"167\" y2=\"42\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"311\" y1=\"42\" x2=\"327\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"400\" y1=\"69\" x2=\"400\" y2=\"102\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"355\" y1=\"69\" x2=\"291.7\" y2=\"107\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"169\" y1=\"138\" x2=\"153\" y2=\"138\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"467.5\" y1=\"69\" x2=\"567.5\" y2=\"109\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"640\" y1=\"165\" x2=\"640\" y2=\"205\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">Sender</tspan></text><rect class=\"dg-node dg-box\" x=\"172\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"38.5\">Connection</tspan><tspan x=\"240\" y=\"54.5\">server A</tspan></text><rect class=\"dg-node dg-box\" x=\"332\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"46.5\">Chat service</tspan></text><path class=\"dg-node dg-store\" d=\"M492,20 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,20 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"49.5\">Message store</tspan></text><rect class=\"dg-node dg-box\" x=\"172\" y=\"112\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"134.5\">Connection</tspan><tspan x=\"240\" y=\"150.5\">server B</tspan></text><rect class=\"dg-node dg-client\" x=\"12\" y=\"114\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"142.5\">Recipient</tspan></text><path class=\"dg-node dg-store\" d=\"M332,114 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M332,114 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"137.5\">Session</tspan><tspan x=\"400\" y=\"153.5\">registry</tspan></text><rect class=\"dg-node dg-box\" x=\"572\" y=\"114\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"640\" y=\"142.5\">Push service</tspan></text><rect class=\"dg-node dg-client\" x=\"572\" y=\"210\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"640\" y=\"238.5\">APNs / FCM</tspan></text><rect class=\"dg-edge-bg\" x=\"127.1\" y=\"-6\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"160\" y=\"7\" text-anchor=\"middle\">WebSocket</text><rect class=\"dg-edge-bg\" x=\"455.4\" y=\"-9\" width=\"47.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"479\" y=\"4\" text-anchor=\"middle\">append</text><rect class=\"dg-edge-bg\" x=\"408\" y=\"76.5\" width=\"47.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"431.6\" y=\"89.5\" text-anchor=\"middle\">lookup</text><rect class=\"dg-edge-bg\" x=\"490.8\" y=\"80\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"517.5\" y=\"93\" text-anchor=\"middle\">offline</text></svg></div><figcaption>Sending a message to an online and an offline recipient</figcaption></figure>\n<p>The path of one message:</p>\n<ol>\n<li>The sender's app writes a <code>send</code> frame on its WebSocket. Connection server A is stateless apart from its sockets. It forwards the frame to the chat service.</li>\n<li>The chat service checks that the sender belongs to the conversation, assigns the next sequence number and a message ID, and appends the message to the message store.</li>\n<li>Only after the write is durable does it ack the sender. This is the \"sent\" tick. If the ack never arrives, the client retries with the same <code>client_msg_id</code>.</li>\n<li>The chat service looks up each member's devices in the <strong>session registry</strong>, a fast key-value store (Redis or similar) mapping <code>user_id</code> to a list of <code>(device_id, connection_server)</code>. Connection servers write to it when a socket opens and delete on close, with a TTL so entries from crashed servers expire.</li>\n<li>For each online device, it forwards the message to the right connection server, which writes it down the socket. Many designs put a pub/sub layer here so the chat service doesn't need direct connections to hundreds of connection servers.</li>\n<li>For devices with no live session, it hands off to the push service, which calls Apple Push Notification service or Firebase Cloud Messaging. The push only wakes the app or shows a banner. The app then connects and syncs; the push is never the source of truth. The <a href=\"/learn/design-notification-system\">notification system</a> chapter covers that pipeline.</li>\n</ol>\n<p>Connection servers often sit behind a <a href=\"/learn/load-balancer#layer-4-load-balancing\">layer 4 load balancer</a>, because WebSockets are long-lived TCP connections and there's little for the balancer to do with the payload. A layer 7 balancer works too, as long as its idle timeouts allow long-lived connections (see <a href=\"/learn/realtime\">real-time updates</a>). When one of them dies, its 250,000 clients all reconnect at once. Clients must back off with random jitter, or the reconnect storm takes down the next server. See <a href=\"/learn/reliability-patterns\">reliability patterns</a>.</p>\n<h4 id=\"presence\">Presence</h4>\n<p>Presence is the \"online\" dot. The connection server already knows when a socket is alive, so presence is a by-product: the client sends a heartbeat every 20–30 seconds, and the presence store keeps <code>user_id → last_seen</code> with a TTL a bit longer than that. No heartbeat, the key expires, the user shows as offline.</p>\n<p>The expensive part is telling others. If a user with 300 contacts flips state 20 times a day, and you push every flip to every contact, that's 6,000 presence messages a day from one person. Most products cut this down by only pushing presence to people who currently have a chat with that user open, by fetching it lazily when a chat screen loads, by delaying \"offline\" by a few seconds to absorb network flaps, and by not showing it at all in large groups.</p>","headings":[{"id":"high-level-design","title":"High-level design","depth":3},{"id":"presence","title":"Presence","depth":4}]},{"id":"ordering-storage-and-sync","title":"Ordering, storage and sync","html":"<h3 id=\"ordering-storage-and-sync\">Ordering, storage and sync</h3>\n<h4 id=\"message-ids-and-ordering\">Message IDs and ordering</h4>\n<p>Users only care about order <strong>within a conversation</strong>. Nobody can tell whether a message in one chat was stored before a message in another. That lets you pick the conversation as the unit of ordering.</p>\n<p>The server, not the client, decides the order. Phone clocks drift and can be set by hand, so <code>sent_at</code> from the device is only for display.</p>\n<p>There are two common ways to do it:</p>\n<ul>\n<li><strong>A per-conversation sequence number.</strong> One owner (the shard that holds the conversation, or a counter in a strongly consistent store) increments <code>last_seq</code> for each message. The result is gap-free: 41, 42, 43. A client holding 41 that receives 43 knows it missed one. The cost is that every message in a conversation goes through one serialization point, which caps the throughput of a single very busy group. That cap is far above anything a human group produces.</li>\n<li><strong>A time-ordered unique ID</strong>, Snowflake style (see <a href=\"/learn/unique-ids\">unique IDs</a>). Any server can mint one without coordination, and sorting by it gives roughly time order. But IDs from two servers can cross by a few milliseconds of clock skew, and there are gaps, so a client can't detect a missing message from IDs alone.</li>\n</ul>\n<p>A good answer uses both: a globally unique <code>msg_id</code> for references (replies, reactions, deletion), and a per-conversation <code>seq</code> for ordering and gap detection. When two people send at the same instant, whichever reaches the sequencer first is first. That's fine, because there was no \"real\" order between them anyway. What matters is that everyone sees the same one.</p>\n<h4 id=\"storage\">Storage</h4>\n<p>The access patterns are narrow:</p>\n<ul>\n<li>Append a message to a conversation (hundreds of thousands a second).</li>\n<li>Read the latest N messages of a conversation.</li>\n<li>Read everything in a conversation after <code>seq</code> S.</li>\n<li>Rarely, read further back in history.</li>\n</ul>\n<p>There are no joins and no cross-conversation transactions. That points to a <a href=\"/learn/database#wide-column-store\">wide-column store</a> such as Cassandra, ScyllaDB or HBase, or a <a href=\"/learn/database#key-value-store\">key-value store</a> with sorted keys:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">CREATE</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> TABLE</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\"> messages</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  conv_id   </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">bigint</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  bucket    </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">int</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,      </span><span style=\"color:#6A737D;--shiki-dark:#6A737D\">-- e.g. days since epoch / 7</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  seq       </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">bigint</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  msg_id    </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">bigint</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  sender_id </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">bigint</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">  body      </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">text</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">  PRIMARY KEY</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ((conv_id, bucket), seq)</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">) </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WITH</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> CLUSTERING </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">ORDER BY</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (seq </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">DESC</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">);</span></span></code></pre>\n<p>Why this shape works:</p>\n<ul>\n<li>All of a conversation's recent messages live in one partition, stored in <code>seq</code> order. \"Latest 50\" and \"everything after 1,042\" are single sequential reads.</li>\n<li>Writes are appends to an LSM tree, which these stores handle well at high rates.</li>\n<li>Partitioning by <code>conv_id</code> spreads load across nodes with no hot spot other than very busy conversations (<a href=\"/learn/database#sharding\">sharding</a> and <a href=\"/learn/consistent-hashing\">consistent hashing</a>).</li>\n<li>The <code>bucket</code> caps partition size. Without it, a group that has been active for five years becomes one enormous partition. Discord's engineering blog describes exactly this pattern: partition by channel plus a fixed time window.</li>\n</ul>\n<p>A relational database can work at smaller scale, and it's easier to reason about. At 300,000 appends a second you would be <a href=\"/learn/database#sharding\">sharding</a> it by <code>conv_id</code> and avoiding joins anyway, which leaves you with a hand-built version of the same thing.</p>\n<h4 id=\"delivery-and-read-receipts\">Delivery and read receipts</h4>\n<p>There are three states, and each has a clear owner:</p>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th>State</th><th>Meaning</th><th>Who records it</th></tr></thead><tbody><tr><td>Sent</td><td>The server stored it durably</td><td>Chat service, when it acks the sender</td></tr><tr><td>Delivered</td><td>A recipient device received it</td><td>The device sends <code>delivered</code></td></tr><tr><td>Read</td><td>The recipient saw it on screen</td><td>The device sends <code>read</code></td></tr></tbody></table></div>\n<p>Don't store a receipt row per message per reader. Store a <strong>high-water mark</strong> per member per conversation: \"user 7 has read up to seq 1,042\". One small update covers any number of messages, because reading message 1,042 implies reading everything before it. The sender's app compares each message's <code>seq</code> with the other members' marks to draw the ticks.</p>\n<p>In a group, \"read by 14 of 30\" is a count of members whose mark is at or past that seq. That's cheap for 30 members and wasteful for 5,000, which is one reason large groups often drop read receipts.</p>\n<h4 id=\"multi-device-sync-with-a-per-device-cursor\">Multi-device sync with a per-device cursor</h4>\n<p>A user has a phone, a laptop and a tablet. Each needs every message, including the ones the user sent from another device, plus read state.</p>\n<p>Treat the sender's own other devices as recipients, so they get new messages through the same path. Then give each device a <strong>cursor</strong> that records how far it has synced. Two designs are common:</p>\n<ul>\n<li><strong>Per-conversation cursors.</strong> The device remembers the last <code>seq</code> it has for each conversation. On reconnect it asks \"which conversations changed since time T?\", then fetches each one after its stored <code>seq</code>. That's simple, but a user in 400 chats means many small requests.</li>\n<li><strong>A per-user change log.</strong> Every event that touches a user (a new message in any of their chats, a read mark from another device, a membership change) is appended to a per-user log with its own sequence. Each device stores a single cursor into that log. Reconnecting is one request: \"give me everything after 88,310\". The cost is writing each message into every member's log, which is fan-out on write.</li>\n</ul>\n<p>Either way, the device advances its cursor only after it has saved the events locally, so a crash mid-sync just repeats some work. If a device has been offline so long that its cursor points past the log's retention, the server tells it to do a full resync of recent conversations.</p>\n<p>When the user reads a chat on their phone, the phone sends <code>read up_to_seq</code>. That updates <code>last_read_seq</code> and becomes an event in the change log, so the laptop clears its unread badge too.</p>","headings":[{"id":"ordering-storage-and-sync","title":"Ordering, storage and sync","depth":3},{"id":"message-ids-and-ordering","title":"Message IDs and ordering","depth":4},{"id":"storage","title":"Storage","depth":4},{"id":"delivery-and-read-receipts","title":"Delivery and read receipts","depth":4},{"id":"multi-device-sync-with-a-per-device-cursor","title":"Multi-device sync with a per-device cursor","depth":4}]},{"id":"group-fan-out","title":"Group fan-out","html":"<h3 id=\"group-fan-out\">Group fan-out</h3>\n<p>A message to a group is written <strong>once</strong> to storage, then delivered to every member's online devices. That's the same split the primer's <a href=\"/learn/design-twitter\">Twitter design</a> makes between storing a tweet and fanning it out, with a much smaller fan-out per message.</p>\n<p>The delivery cost is linear in group size. A message in a 500-member group, with an average of 1.5 online devices per member, is 750 socket writes. Group size limits exist for this reason, and so does the fact that big groups behave differently. Reasonable rules:</p>\n<ul>\n<li><strong>Small and medium groups</strong> (up to some hundreds of members): full delivery, per-member receipts, typing indicators.</li>\n<li><strong>Large groups or broadcast channels</strong> (thousands and up): deliver only to members who have the chat open, push a lightweight \"new activity\" signal to the rest, and let clients pull on open. Drop read receipts and per-member presence.</li>\n<li><strong>Membership changes</strong> are ordered events in the conversation too, so a member removed at seq 900 doesn't receive seq 901.</li>\n</ul>\n<p>Fan-out happens off the sender's critical path. The sender is acked as soon as the message is stored. Delivery work goes onto a <a href=\"/learn/asynchronism#message-queues\">queue</a> partitioned by <code>conv_id</code>, which keeps each conversation's deliveries in order and absorbs bursts.</p>","headings":[{"id":"group-fan-out","title":"Group fan-out","depth":3}]},{"id":"scaling-and-bottlenecks","title":"Scaling and bottlenecks","html":"<h3 id=\"scaling-and-bottlenecks\">Scaling and bottlenecks</h3>\n<ul>\n<li><strong>Connection servers</strong> scale horizontally. The limits are memory per connection and the reconnect storm after a failure. Drain servers gracefully on deploy: tell clients to reconnect elsewhere over a few minutes instead of cutting everyone at once.</li>\n<li><strong>Session registry.</strong> Every message triggers a lookup per member. Shard it by <code>user_id</code>, keep it in memory, and accept that it's slightly stale. A message sent to a dead connection server fails, and the device catches up through sync when it reconnects.</li>\n<li><strong>Hot conversations.</strong> A huge, very active group puts its sequencer and its storage partition on one node. Caps on group size, the time bucket in the partition key, and the separate large-group mode all help.</li>\n<li><strong>Multi-region.</strong> Users connect to the nearest region. A conversation still needs one sequencer, so pick a home region per conversation (for example, where it was created) and route sends there. Cross-region members pay one extra hop.</li>\n<li><strong>Retention and cold history.</strong> Old messages are rarely read. Moving buckets past a certain age to cheaper storage keeps the hot cluster small.</li>\n</ul>","headings":[{"id":"scaling-and-bottlenecks","title":"Scaling and bottlenecks","depth":3}]},{"id":"trade-offs-to-discuss","title":"Trade-offs to discuss","html":"<h3 id=\"trade-offs-to-discuss\">Trade-offs to discuss</h3>\n<ul>\n<li><strong>Per-conversation <code>seq</code> vs time-ordered IDs.</strong> Gap detection and simple sync, at the cost of a serialization point per conversation.</li>\n<li><strong>Fan-out on write vs on read for sync.</strong> A per-user change log makes reconnects one request, but costs a write per member per message. Per-conversation cursors are cheaper to write and slower to sync.</li>\n<li><strong>WebSockets vs alternatives.</strong> WebSockets give low-latency two-way traffic, but they're stateful connections you have to balance and drain. Long polling or server-sent events are simpler to operate and slower. <a href=\"/learn/realtime\">Real-time communication</a> compares them.</li>\n<li><strong>Receipts and presence vs cost.</strong> Both scale with group size and contact count. Large groups usually get fewer features, and users accept that.</li>\n<li><strong>End-to-end encryption</strong>, as a talking point. With E2EE the server only stores and forwards ciphertext. Signal's published protocol combines an initial key agreement (X3DH, and since 2023 a post-quantum variant, PQXDH) with a ratchet that changes keys as the conversation goes on, the Double Ratchet. Signal has kept extending both against quantum attacks, so check its docs for the current versions. Groups use sender keys or the newer Messaging Layer Security standard. The design consequences are what to raise in an interview: the server can't search messages, build link previews or scan content; every device has its own keys, so with pairwise encryption the sender encrypts once per recipient device (sender keys cut that to once per message for groups); and adding a device or recovering history needs a key-transfer story.</li>\n</ul>","headings":[{"id":"trade-offs-to-discuss","title":"Trade-offs to discuss","depth":3}]},{"id":"check-yourself","title":"Check yourself","html":"<h3 id=\"check-yourself\">Check yourself</h3>\n<details>\n<summary>A user's phone shows the message \"ok\" before \"are you free?\", but the other person typed them the other way round. What went wrong, and how does your design prevent it?</summary>\n<p>The client is probably ordering by device timestamp, or by arrival order over two different connections. In this design the chat service assigns a per-conversation <code>seq</code> when it stores each message, and every client sorts by <code>seq</code>. Two messages from the same sender over the same socket reach the sequencer in the order they were sent, as long as the connection server forwards each conversation's frames in order, so they get increasing <code>seq</code> values and display correctly everywhere. A retry is the exception: if the first send times out and is resent after the second went through, it gets the later <code>seq</code>. Clients that need strict order hold later sends until earlier ones are acked.</p>\n</details>\n<details>\n<summary>A laptop has been closed for a week. Walk through what happens when it reconnects.</summary>\n<p>It opens a WebSocket, registers in the session registry, and sends <code>sync</code> with its stored cursor. The server returns the events after that cursor in pages: new messages, read marks set from other devices, membership changes. The laptop saves each page locally before advancing its cursor. If the cursor is older than the change log's retention, the server replies \"resync\", and the laptop instead fetches the latest messages of each recent conversation and starts a fresh cursor.</p>\n</details>\n<details>\n<summary>Why store \"read up to seq N\" per member instead of a read flag per message, and when does even that become too expensive?</summary>\n<p>Reading is monotonic: if you've read message 1,042 you've read everything before it, so one number captures it and one small write updates it no matter how many messages you just scrolled past. Per-message flags would cost a write per message per reader. It gets expensive in very large groups, because every read mark is an event other members' devices may need, and showing \"read by\" counts means comparing thousands of marks. That's why large groups usually turn receipts off.</p>\n</details>\n<details>\n<summary>A connection server holding 250,000 sockets crashes. What breaks, and what keeps it from getting worse?</summary>\n<p>Messages being forwarded to that server fail, and its session registry entries are stale until their TTL expires. No messages are lost, because they were stored before fan-out; affected devices catch up through <code>sync</code> when they reconnect. The danger is 250,000 clients reconnecting in the same second, so clients use exponential backoff with random jitter, and the load balancer spreads them over the remaining servers. You also need enough spare capacity for those servers to absorb the extra connections.</p>\n</details>\n<details>\n<summary>Your product team wants server-side search across all messages. How does that interact with end-to-end encryption?</summary>\n<p>With E2EE the server only holds ciphertext, so it can't build a search index. You either search on the device, over its local copy of history (limited to what that device has synced), or you give up E2EE for the conversations that need server search. That trade-off is a product decision, not just a technical one, so it's worth naming it in the interview rather than quietly picking one side.</p>\n</details>\n<h3 id=\"further-reading\">Further reading</h3>\n<ul>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc6455\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 6455: The WebSocket Protocol</a>: the handshake, framing and close semantics your connection servers implement.</li>\n<li><a href=\"https://discord.com/blog/how-discord-stores-billions-of-messages\" target=\"_blank\" rel=\"noopener noreferrer\">How Discord stores billions of messages</a>: a message table partitioned by channel and time bucket in Cassandra, and the problems it hit.</li>\n<li><a href=\"https://discord.com/blog/how-discord-stores-trillions-of-messages\" target=\"_blank\" rel=\"noopener noreferrer\">How Discord stores trillions of messages</a>: the follow-up, covering hot partitions and the move to ScyllaDB.</li>\n<li><a href=\"https://signal.org/docs/\" target=\"_blank\" rel=\"noopener noreferrer\">Signal protocol specifications</a>: X3DH, PQXDH and the Double Ratchet, for the E2EE discussion.</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc9420\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9420: The Messaging Layer Security (MLS) Protocol</a>: the IETF standard for end-to-end encrypted group messaging.</li>\n<li><a href=\"https://firebase.google.com/docs/cloud-messaging\" target=\"_blank\" rel=\"noopener noreferrer\">Firebase Cloud Messaging documentation</a>: how push delivery to offline devices works on Android and the web.</li>\n<li>Alex Xu, <em>System Design Interview – An Insider's Guide</em> (2020), \"Design a chat system\".</li>\n<li><a href=\"https://systemdesignschool.io/primer\" target=\"_blank\" rel=\"noopener noreferrer\">System Design School primer</a>: \"NoSQL Databases\" and \"Message Queues\".</li>\n</ul>","headings":[{"id":"check-yourself","title":"Check yourself","depth":3},{"id":"further-reading","title":"Further reading","depth":3}]}],"sourceFiles":[]}