Full-stackHard45 min

Design a Real-Time Chat App

A full-stack favorite: WebSockets and optimistic UI on the client, fan-out and storage on the server.

Say it in 30 seconds
  • 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

0 of 0 sections ticked
30-second answer

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

QuantityCalculationResult
Messages50M DAU × 40 messages/day = 2B/day≈ 23K/s (≈ 70K/s peak)
Storage2B × ~200 B≈ 400 GB/day
Connections10M 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.

Interactive

Capacity calculator

Move any slider: these are the numbers to say out loud.

More assumptions

Ballparks at 10 actions each: 100K DAU ≈ 11.6 QPS · 1M DAU ≈ 115.7 QPS · 10M DAU ≈ 1.2K QPS · 100M DAU ≈ 11.6K QPS · 1B DAU ≈ 115.7K QPS.

Average QPS
46,296
23,148 reads · 23,148 writes
Peak QPS
138,889
3× average
Storage
438 TB
400 GB/day × 1y × 3 replicas
Read egress
4.6 MB/s
≈ 0 Gbps
App servers
47
at 60% of 5,000 req/s each
Cache (hot 20%)
80.0 GB
upper bound for a day's reads

Formula: QPS = 50M × 80 ÷ 86,400 = 46,296.

API

events: one socket per device, JSON frames with a type field.

Read more · 108 words
DirectionEventPayload
Client → serversend{ clientId, conversationId, text }
Client → serversync{ conversationId, after } (the last seq the client has)
Client → serverread{ conversationId, seq }
Client → servertyping{ conversationId } (throttled)
Server → clientack{ clientId, seq }
Server → clientmessageThe stored message, including seq
Server → clientreceipt{ conversationId, userId, readUpTo }
Server → clienttyping, presenceEphemeral, never stored

REST for the rest:

MethodPathNotes
GET/conversationsMine, with unread counts
GET/conversations/:id/messages?before=SEQ&limit=50History, newest first
POST/uploadsA 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 :

KeyTypePurpose
seq:CONVERSATION_IDcounterINCR gives the next seq
user:USER_IDpub/sub channelEvery gateway holding that user's socket subscribes
presence:USER_IDstring, EX 70Exists 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 }
indexes.js
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.

UsersPolling every 5 sVerdict
1,000200 requests/sPolling is fine
100,00020,000/s, mostly emptyLong polling, or sockets
10M2M requests/sWebSockets

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.

Pub/sub is a radio tower: the publisher broadcasts once and every listener tuned in hears it.

Press Play to watch it run · 9 steps

Clients hold WebSockets to any gateway. The chat service assigns a per-conversation seq, stores the message in MongoDB, then publishes it on Redis so whichever gateway holds the recipient's sockets can deliver it. Offline users get a push notification and sync later.

Real-time chat architectureClients hold WebSockets to any gateway. The chat service assigns a per-conversation seq, stores the message in MongoDB, then publishes it on Redis so whichever gateway holds the recipient's sockets can deliver it. Offline users get a push notification and sync later.WebSocketWebSocketpublishstorepresignofflineAlicephoneBoblaptop + phoneLoad balancerL4, long-livedWS gateway 1~50K socketsRouting only: fire-and-forget, no durability. Each gateway subscribes to user:<id> for every user it holds a socket for.Redis pub/subuser:<id>WS gateway 2~50K socketsChecks membership, dedupes by (from, clientId), INCR seq:<conversation>, stores, then fans out.Chat serviceseq + storeThe source of truth. Indexed by (conversationId, seq) for history and sync.MongoDBreplica setObject storagemedia via CDNPush serviceoffline onlyAPNs / FCM

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
Open in playground
Text description
  1. Alice sends { clientId, conversationId, text } over her WebSocket.
  2. The load balancer forwards the frame to gateway 1, which holds Alice's socket.
  3. The chat service checks Alice is a member, finds no earlier message with this clientId, and runs INCR seq:c42, which returns 1043.
  4. The message is stored in MongoDB with seq 1043.
  5. The write is acknowledged. The message is now durable.
  6. Only now does Alice get her ack, and at the same time the message is published to every member's channel, including user:bob.
  7. 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.
  8. Alice's bubble flips from sending to sent (one tick), and gateway 2 forwards the message toward Bob.
  9. Gateway 2 writes it to every socket Bob has open, laptop and phone. About 100 ms end to end in one region.
Interactive

Chat delivery simulator

Lost acks cause retries; the server dedupes them by clientId.

ABseq 0
Alice online

No messages yet

Bob online

No messages yet

Type a message on either phone and press send. Totals: 0 retries, 0 duplicates ignored, 0 packets lost.

Server and network log

Nothing yet: send a message.

    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:

    1. Clarify

      Ask, then name the number that drives everything: 10M open sockets.

    2. Choose the transport

      online, push offline, REST for history and uploads. does the routing, so no .

    3. Keep a connection registry on each gateway

      userId → Set<socket>, because one user has several devices. Subscribe to user:ID while 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);
    4. Send path: store, then ack, then fan out

      Check membership, by (from, clientId), get a seq, 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', /* … */ });
    5. Ordering

      Per conversation, by seq. Clients sort and tolerate gaps. Nobody compares order across chats, so there is no global bottleneck.

    6. Offline delivery and sync

      On every reconnect, the client sends sync with its last seq, and the server replays the rest. That also repairs any missed live message. Members who are offline get a push notification.

    7. Receipts as watermarks

      Keep a lastReadSeq and lastDeliveredSeq per member, not a row per message. Reading 1042 implies everything before it.

    8. 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.

    9. Groups and very large channels

      Small groups fan out on write. Big channels fan out on read: members pull on open.

    10. Client: optimistic and resilient

      Show the bubble at once as sending. After reconnect, resend pending messages with the same clientId. Random (jittered) stops 50K clients returning together.

    Press Play to watch it run · 11 steps

    Gateway 1 stores the message and only then acks Alice. What happens next depends on whether Bob is online, offline, or whether the ack gets lost on the way back.

    One message, three endingsGateway 1 stores the message and only then acks Alice. What happens next depends on whether Bob is online, offline, or whether the ack gets lost on the way back.send { clientId: c1 }Check membership, dedupe by (from, c1)INCR seq:conv421043insertOne { seq: 1043, clientId: c1, … }ok (durable)alt [Bob online]ack { seq: 1043 }PUBLISH user:bobmessage (user:bob)message seq 1043read { seq: 1043 }lastReadSeq = 1043 if lowerWatermark moved: receipt broadcast, Alice sees read
    • Client
    • API gateway
    • Pub/Sub
    • Database
    Text description
    1. Alice → Gateway 1: send { clientId: c1 }
    2. Gateway 1 → Redis: INCR seq:conv42
    3. Redis replies to Gateway 1: 1043
    4. Gateway 1 → MongoDB: insertOne { seq: 1043, clientId: c1, … }
    5. MongoDB replies to Gateway 1: ok (durable)
    6. Gateway 1 replies to Alice: ack { seq: 1043 }
    7. Gateway 1 sends (async) to Redis: PUBLISH user:bob
    8. Redis sends (async) to Gateway 2: message (user:bob)
    9. Gateway 2 sends (async) to Bob: message seq 1043
    10. Bob → Gateway 2: read { seq: 1043 }
    11. 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 breaksEffectMitigation
    A gateway diesIts ~50K sockets dropJittered reconnect; sync from the last seq
    A pub/sub message is lostA live message is missedSync on reconnect and on open
    Retry after a lost ackDuplicate messageUnique (from, clientId); re-ack
    Out-of-order arrivalWrong display orderClients sort by seq
    A huge groupFan-out stormFan out on read
    Redis is downNo routing, no new seqsReplicated Redis; sync recovers
    Mass reconnect after a deployThundering herdDrain slowly; client jitter

    Trade-offs

    ChoiceGainCost
    WebSocket, not long pollingFast, two-wayStateful connections
    Redis pub/sub, not KafkaSimple and fastNo replay
    Fan-out on writeFast readsHeavy writes for big groups
    MongoDB, not CassandraSimplerLess 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 3

    Score 2 out of 3 or better to mark this page complete.

    1. When does the server ack a message to the sender?