System Design
C6

Chat and presence

A messaging app with one-to-one and group chats, delivery and read receipts, and who is online. The hard parts are routing to the right gateway, ordering, and losing nothing when a gateway dies.

Not startedSaved in this browser only.
  1. 1Each device holds a WebSocket to a gateway. A Redis registry says which gateway holds which device; gateways pass frames over Pub/Sub.
  2. 2Store first, then deliver. Each conversation numbers its messages under its row lock, and a client message ID makes a retry harmless.
  3. 3Delivery is best effort; the sequence number is the truth. A device that missed messages syncs after the last number it showed.
  4. 4Presence is a lease: a TTL key that heartbeats renew. Fetch it only for the people on screen.
C6
    A

    The prompt

    as the interviewer says it
    "Design a messaging app: one-to-one and group chat, delivery receipts, and who is online."
    questionanswer assumed
    How many users?500 million daily; 30% online at peak.
    Messages?40 a day per user, text up to 100 bytes on average.
    Group size?Up to 500 members. Larger channels are a follow-up.
    Devices?Several per user. Each shows the same history and read state.
    History?Kept on the server; a new device syncs it.
    Offline users?Get a push notification, then the messages on the next connect.
    Encryption?End to end is a follow-up; the server stores ciphertext then.

    I confirm group size, devices per user, how long history lives, and whether the server may read messages.

    B

    Requirements

    as numbers
    functional
    Send to a person or a group; see it on every device.
    Delivered and read receipts.
    Online or last seen for a contact.
    History and catch-up after offline time.
    non-functionaltarget
    Send to delivery, both onlinep99 < 300 ms
    Acked messages lost0
    OrderThe same per conversation on every device
    Duplicates shown0
    Presence staleness≤ 1 TTL (60 s)

    Nothing acked may be lost, and every device must show a conversation in the same order.

    C

    Estimates

    the arithmetic, on the board
    quantityarithmeticresult
    Messages500 M × 40 a day = 20 B ÷ 86,400≈ 231,000/s; 3× peak ≈ 694,000/s
    Open sockets500 M × 30% online150 M
    Gateways150 M ÷ 100 k sockets each (a budget to load test)≈ 1,500
    Storage20 B × 100 B a day2 TB a day ≈ 730 TB a year, one copy
    Heartbeats150 M ÷ 30 s5 M renewals/s, batched per gateway
    Presence, push to all friends500 M × 10 changes × 200 friends ÷ 86,400≈ 11.6 M events/s
    Presence, lazy500 M × 10 changes × 2 viewers ÷ 86,400≈ 116,000 events/s
    Message stores231,000/s ÷ 4,400/s measured per node≈ 53 Postgres shards

    Inputs are the assumed answers in panel A. The per-node rate is the lab's one-to-one case on one laptop, so the shard count is an order of magnitude. Method: R2.

    About 231,000 messages a second and 150 million open sockets at peak. Presence is the biggest number on the board, so I fetch it lazily.

    D

    API

    frames on the socket, plus REST
    framedirectioncarriesanswer
    senddevice → gatewayconv, client_msg_id, bodyack: seq, dup
    msggateway → deviceconv, seq, from, bodyrecv receipt
    recv / readdevice → gatewayconv, seq (a high-water mark)receipt to others
    syncdevice → gatewayconv, after seqmsg ..., synced
    hbdevice → gatewaynothing; every 30 snone
    watchdevice → gatewayusers on screenpresence frames
    callsuccesserrors
    GET /v1/conversations/{id}/messages?after=&limit=200 + messages403, 404
    POST /v1/conversations {members} + Idempotency-Key201 + id400, 422
    POST /v1/devices {push_token}201400
    GET /ws?device= (upgrade)101401, 426
    • The client makes the client message ID once per message and reuses it on every retry.
    • A sequence number in the ack lets the sender place its own message in the right position.

    Real-time traffic goes over one WebSocket as small JSON frames. History, groups and push tokens use plain HTTP.

    E

    Data model

    Postgres is the truth, Redis is the routing state
    the truth: three tables in Postgres1 : n, seq 1, 2, 3 ...1 : nconversationsPKidkindlast_seqrow lockone row per chatmembersPK FKconv_idPKuser_iddelivered_seqread_seqidx (user_id, conv_id)messagesPKconv_idPKseqUQsenderUQclient_msg_idbodyhash partitions by conv_id
    Redis keyholdslifetime
    sess:{user}Hash: device → gatewayTTL, renewed by heartbeats
    presence:{user}String: the gateway of the last heartbeatTTL 60 s
    gw:{id}Pub/Sub channel of one gatewayWhile it runs
    pres:{user}Pub/Sub channel of one user's presenceWhile someone watches
    conversations, members, messagessql
    CREATE TABLE conversations (
      id       bigint PRIMARY KEY,
      kind     text   NOT NULL CHECK (kind IN ('direct', 'group')),
      last_seq bigint NOT NULL DEFAULT 01
    );
    
    CREATE TABLE members (
      conv_id       bigint NOT NULL REFERENCES conversations (id),
      user_id       text   NOT NULL,
      delivered_seq bigint NOT NULL DEFAULT 0,
      read_seq      bigint NOT NULL DEFAULT 0,
      PRIMARY KEY (conv_id, user_id),
      CHECK (read_seq <= delivered_seq)2
    );
    CREATE INDEX members_user ON members (user_id, conv_id);
    
    CREATE TABLE messages (
      conv_id       bigint      NOT NULL,
      seq           bigint      NOT NULL,
      sender        text        NOT NULL,
      client_msg_id text        NOT NULL,
      body          text        NOT NULL,
      sent_at       timestamptz NOT NULL DEFAULT now(),
      PRIMARY KEY (conv_id, seq)3,
      UNIQUE (conv_id, sender, client_msg_id)4
    ) PARTITION BY HASH (conv_id)5;
    CREATE TABLE messages_p0 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 0);
    CREATE TABLE messages_p1 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 1);
    CREATE TABLE messages_p2 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 2);
    CREATE TABLE messages_p3 PARTITION OF messages FOR VALUES WITH (MODULUS 4, REMAINDER 3);
    1. 1The counter for this conversation. The row lock on it orders every send.
    2. 2Receipts are marks, one row per member. Read implies delivered.
    3. 3History of one conversation is one index range, in order.
    4. 4A retry finds its first copy. The key includes conv_id, so each partition can enforce it alone.
    5. 5All of a conversation lives in one partition; later, one shard.

    Messages are keyed by conversation and sequence number and partitioned by conversation. Redis holds only state that a reconnect rebuilds: the registry and presence.

    F

    The architecture

    click a step; its path lights up
    Alice's phoneWebSocketGateway G1holds socketsPostgresmessages, marksRedisregistry, presenceRedis Pub/Subgw:G1, gw:G2Push providerAPNs, FCMGateway G2holds socketsBob's phoneWebSocket

    Step 1: Connect

    • The device opens a WebSocket through the load balancer to any gateway.
    • The gateway writes device → gateway into the registry hash of the user.
    • It subscribes to its own channel, gw:{id}, once at start.

    If it fails

    The gateway dies: the socket drops and the device reconnects to another gateway. Old entries stay until a router finds them dead or the TTL ends.

    The sender's gateway stores the message, finds the recipient's gateway in the registry, and publishes to it. A device that was away syncs from Postgres.

    G

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    RedisHashes with a key TTLThe registry: all devices of a user in one key that expires without heartbeats.Sessions, carts
    RedisSET with EX and GETRenew presence and learn, in one command, whether the user was offline.Leases, locks
    RedisPub/Sub; PUBLISH returns the receiver countGateway to gateway delivery. A count of 0 means the gateway is dead.Cache invalidation, live updates
    RedisPub/Sub stores nothingLimit A frame for a dead gateway is gone. Store first, then publish.
    RedisSharded Pub/Sub (SPUBLISH, Redis 7)Channels spread over cluster shards instead of every node.Large fan-out
    RedisScriptsRemove a registry entry only if it still names the dead gateway.Compare-and-delete
    PostgresUPDATE ... RETURNING under a row lockA gap-free sequence number per conversation.Invoice numbers, versions
    PostgresUnique constraintOne stored copy per client message ID.Idempotency keys
    PostgresHash partitioningConversations spread over partitions; one conversation stays in one.Tenants, devices
    PostgresGREATEST in an UPDATEReceipt marks only move forward, in any arrival order.Watermarks, offsets
    GatewayWebSocket (RFC 6455)One long-lived, two-way socket per device.Live dashboards, games
    APNs, FCMPush notificationsWake an offline device. The text stays on the server.Alerts, reminders

    Redis gives me a registry with expiry and a channel per gateway. Postgres gives me a per-conversation counter under a row lock and a unique key for retries.

    H

    Deep dive: routing to the right gateway

    who holds Bob's socket
    methodstatuswhy
    One server holds every socketOne machine's socketsNo routing at all, until it is full or down.
    Broadcast every message to every gatewayNot approvedWork grows with gateways × messages.
    Registry in Redis, Pub/Sub channel per gatewayApprovedOne lookup and one publish per gateway that holds a device.
    Registry, then a direct call to the gatewayGateways can reach each otherNo broker in the path; needs a gateway address book.
    Sticky routing: hash user to gatewayOne device per userA second device or a rebalance breaks the rule.
    A durable queue per userSmall scaleMillions of queues. Postgres already keeps the history.
    • Gateways hold no message state. Any gateway can serve any device after a reconnect.
    • The load balancer spreads new sockets; it needs no stickiness for chat.
    route one framepseudo code
    route(user, frame):
      devices = HGETALL sess:{user}1                  // device -> gateway
      FOR EACH gateway g holding some of them:
        n = PUBLISH gw:{g}2 (user, devices, frame)
        IF n == 0:3                                   // nobody subscribed: g is dead
          remove g's entries4 for user from the registry
      IF no gateway took it AND frame is a message:
        queue a push notification                    // the message waits in Postgres5
    1. 1One key per user holds every device and its gateway.
    2. 2One publish per gateway, carrying the list of devices on it. A group send costs members × gateways publishes.
    3. 3PUBLISH returns how many subscribers got it. A crashed gateway has none.
    4. 4A script deletes an entry only if it still names g, so a fresh reconnect is never removed.
    5. 5Stored before routing, so a failed route loses nothing.
    Tested source Go: route
    Go: routego
    // route looks up where the user's devices are connected and publishes one envelope per gateway.
    // A gateway that receives nothing is dead: its entries leave the registry. A user with no live
    // device gets a push notification for a message; the message itself waits in Postgres.
    func (g *Gateway) route(ctx context.Context, user, except string, f Frame) error {
      devices, err := g.rdb.HGetAll(ctx, g.cfg.sessKey(user)).Result()
      if err != nil {
        return fmt.Errorf("registry %s: %w", user, err)
      }
      byGW := map[string][]string{}
      for d, gw := range devices {
        if d != except {
          byGW[gw] = append(byGW[gw], d)
        }
      }
      delivered := false
      for gw, ds := range byGW {
        slices.Sort(ds)
        p, err := json.Marshal(envelope{User: user, Devices: ds, Frame: f})
        if err != nil {
          return fmt.Errorf("marshal envelope: %w", err)
        }
        n, err := g.rdb.Publish(ctx, g.cfg.gwChan(gw), p).Result()
        if err != nil {
          return fmt.Errorf("publish to %s: %w", gw, err)
        }
        g.Stats.Published.Add(1)
        if n > 0 {
          delivered = true
          continue
        }
        g.Stats.DeadRoutes.Add(1)
        args := append([]any{gw}, anys(ds)...)
        if err := dropStale.Run(ctx, g.rdb, []string{g.cfg.sessKey(user)}, args...).Err(); err != nil {
          return fmt.Errorf("drop stale entries of %s: %w", user, err)
        }
      }
      if !delivered && f.T == "msg" && except == "" {
        if err := g.rdb.RPush(ctx, g.cfg.pushKey(user), fmt.Sprintf("%d:%d", f.Conv, f.Seq)).Err(); err != nil {
          return fmt.Errorf("queue push for %s: %w", user, err)
        }
        g.Stats.Pushes.Add(1)
      }
      return nil
    }
    

    I keep a registry of device to gateway in Redis and publish on the target gateway's channel. A publish that nobody receives tells me the gateway is dead.

    I

    Deep dive: order, retries, receipts

    one number per message per conversation
    AliceCarolconversation rowlast_seq 6, locked per sendDave's phonesend "lunch?"send "12:30"ack seq 7ack seq 8seq 8 arrives firstseq 7 arrives latehold 8: 7 is missingshow 7, then 8✓ Every device shows 7 before 8, whatever order the network used
    store, then routepseudo code
    send(device, conv, client_msg_id, body):
      BEGIN
        seq = conversations.last_seq + 1 WHERE id = conv   // locks the row1
        IF (conv, sender, client_msg_id) already stored2:
          ROLLBACK; ACK the stored seq3                     // a retry
        insert (conv, seq, sender, client_msg_id, body)
      COMMIT
      ACK seq to the sender
      FOR EACH member m, FOR EACH device of m except this one4:
        route(m, message)
    1. 1Every send to this conversation waits here in turn. The number has no gaps.
    2. 2The unique key (conversation, sender, client message ID). A retry ends here.
    3. 3The retry gets the first number back. The rollback returns the number it took.
    4. 4The sender's other devices get the message too, so every device shows the same history.
    Tested source Go: send and store
    Go: send and storego
    // send stores the message with the next sequence number of its conversation, acks the sender,
    // and routes the message to every device of every member. A retry with the same client message
    // ID gets the first sequence number back and is not routed again.
    func (g *Gateway) send(ctx context.Context, s *session, f Frame) error {
      seq, dup, err := g.store(ctx, f.Conv, s.user, f.CMID, f.Body)
      if err != nil {
        return err
      }
      g.reply(s, Frame{T: "ack", Conv: f.Conv, CMID: f.CMID, Seq: seq, Dup: dup})
      if dup {
        return nil
      }
      msg := Frame{T: "msg", Conv: f.Conv, Seq: seq, From: s.user, CMID: f.CMID, Body: f.Body}
      members, err := g.membersOf(ctx, f.Conv)
      if err != nil {
        return err
      }
      for _, m := range members {
        except := ""
        if m == s.user {
          except = s.device // the sender's other devices get a copy
        }
        if err := g.route(ctx, m, except, msg); err != nil {
          return err
        }
      }
      return nil
    }
    
    // store takes the next sequence number under the conversation's row lock. The lock also orders
    // two copies of one retry, so the second finds the first and reuses its number.
    func (g *Gateway) store(ctx context.Context, conv int64, sender, cmid, body string) (seq int64, dup bool, err error) {
      tx, err := g.db.Begin(ctx)
      if err != nil {
        return 0, false, fmt.Errorf("begin: %w", err)
      }
      defer func() {
        if err != nil {
          err = errors.Join(err, tx.Rollback(ctx))
        }
      }()
      if err := tx.QueryRow(ctx, Q("next_seq"), conv, sender).Scan(&seq); err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
          return 0, false, fmt.Errorf("%s is not in conversation %d", sender, conv)
        }
        return 0, false, fmt.Errorf("next seq: %w", err)
      }
      var first int64
      switch err := tx.QueryRow(ctx, Q("find_sent"), conv, sender, cmid).Scan(&first); {
      case err == nil:
        return first, true, tx.Rollback(ctx) // a retry: give back the number just taken
      case !errors.Is(err, pgx.ErrNoRows):
        return 0, false, fmt.Errorf("find sent: %w", err)
      }
      if _, err := tx.Exec(ctx, Q("insert_message"), conv, seq, sender, cmid, body); err != nil {
        return 0, false, fmt.Errorf("insert message: %w", err)
      }
      if err := tx.Commit(ctx); err != nil {
        return 0, false, fmt.Errorf("commit: %w", err)
      }
      return seq, false, nil
    }
    
    order bystatuswhy
    Device clockNot approvedPhones' clocks are wrong by minutes.
    Gateway timestampNot approvedTwo gateways' clocks differ (X4).
    Sequence per conversationApprovedOne counter, one order, gaps are visible.
    One global sequenceNot approvedEvery message in the system waits on one counter.
    Time-ordered ID per messageOrder across chats onlySorts roughly by time, but a gap cannot be detected.
    the devicepseudo code
    on_message(conv, seq):
      IF seq <= last[conv]: drop it1                  // already shown
      IF seq > last[conv] + 1:
        hold it; sync(conv, after = last[conv])      // fill the gap2
      ELSE:
        show it, then every held seq that now fits
        send a delivered receipt up to last[conv]3
    1. 1A live copy and a sync copy of the same number: show it once.
    2. 2Two gateways can deliver 8 before 7. Hold 8 and fetch from the last shown number.
    3. 3One mark covers every message up to it.
    Tested source Go: the device
    Go: the devicego
    func (c *Client) handle(f Frame) {
      c.mu.Lock()
      defer c.mu.Unlock()
      defer c.notify()
      switch f.T {
      case "ack": // the sender's own copy takes its place by sequence number, like any other
        c.acks[f.CMID] = append(c.acks[f.CMID], f)
        sent := c.outbox[f.CMID]
        c.onMsg(Frame{T: "msg", Conv: f.Conv, Seq: f.Seq, From: c.User, CMID: f.CMID, Body: sent.Body})
      case "msg":
        c.onMsg(f)
      case "synced":
        c.synced[f.CMID] = true
        c.syncing[f.Conv] = false
        if len(c.held[f.Conv]) > 0 { // a newer gap opened during the sync
          c.syncing[f.Conv] = true
          go c.requestSync(f.Conv, c.last[f.Conv])
        }
      case "receipt":
        if c.marks[f.Conv] == nil {
          c.marks[f.Conv] = map[string]Marks{}
        }
        m := c.marks[f.Conv][f.User]
        if f.Kind == "read" {
          m.Read = max(m.Read, f.Seq)
        }
        m.Delivered = max(m.Delivered, f.Seq)
        c.marks[f.Conv][f.User] = m
      case "presence":
        c.presence[f.User] = f.State
      case "error":
        c.errs = append(c.errs, f.Err)
      }
    }
    
    // onMsg shows a message only when it is the next sequence number. A number already shown is a
    // duplicate; a number past a gap waits until a sync fills the gap. The caller holds c.mu.
    func (c *Client) onMsg(f Frame) {
      conv, last := f.Conv, c.last[f.Conv]
      if f.Seq <= last || c.held[conv][f.Seq].Seq != 0 {
        c.dups++
        return
      }
      m := Msg{Seq: f.Seq, From: f.From, CMID: f.CMID, Body: f.Body}
      if f.Seq > last+1 { // a gap: hold this one and fetch what is missing
        if c.held[conv] == nil {
          c.held[conv] = map[int64]Msg{}
        }
        c.held[conv][f.Seq] = m
        if !c.syncing[conv] {
          c.syncing[conv] = true
          go c.requestSync(conv, last)
        }
        return
      }
      for ok := true; ok; m, ok = c.held[conv][last+1] {
        delete(c.held[conv], m.Seq)
        c.shown[conv] = append(c.shown[conv], m)
        last = m.Seq
        if c.OnShow != nil {
          c.OnShow(conv, m)
        }
      }
      c.last[conv] = last
      if c.AutoReceipt {
        go c.receipt("recv", conv, last)
      }
    }
    

    The conversation row lock hands out sequence numbers. Devices show messages in that order, drop numbers they have, and sync when a number is missing.

    J

    See it: send, deliver, ack, crash, catch up

    recorded over real WebSockets, Redis and Postgres
    alice/phonesocket to G1shows 1bob/phonesocket to G2shows 1Gateway G1upGateway G2upPUBLISH calls: 1reached no gateway: 0Redisregistry: device → gatewayalice/phone: G1bob/phone: G2presence keys (TTL)online: alice, bobpush notifications queuednonePub/Sub channelsgw:G1, gw:G2Postgres: conversation 1seq 1 alice: hireceipts: delivered / readalice: 0 / 0bob: 0 / 0
    step 1 of 7

    After this step

    stored
    1
    alice/phone
    G1; shows 1
    bob/phone
    G2; shows 1

    Two gateways run in one test process, each with its own listener and Redis connection; they share only Redis and Postgres. The lab also runs 7 devices sending 420 messages through a gateway crash, with a fifth of sends retried. It checks: no acked message lost, one stored row per client message ID, and every device shows sequence 1 to n in order.

    When a gateway crashes, the message is already stored. The router finds the dead gateway, queues a push, and the device catches up by sequence number.

    K

    Deep dive: groups and presence

    where fan-out costs grow
    group sizedeliveryreceipts
    2 to 500Route to every member's devices at send time.Per member
    Thousands or more (a channel)Notify online members; others sync when they open it.Counts only
    presence methodstatuscost here
    Push every change to every friendNot approved≈ 11.6 M events/s
    Subscribe for people on screenApproved≈ 116,000 events/s
    Clients poll each friendSmall contact listsGrows with friends × poll rate
    Delete the key on disconnect onlyNot approvedA crash leaves users online forever
    TTL key renewed by heartbeatsApprovedStale by at most one TTL

    In the lab a group of 10 delivered 5,500 copies a second for 600 messages: 9 copies each.

    heartbeats and watchingpseudo code
    heartbeat(device):                    // every 30 s from each device
      EXPIRE sess:{user} SESSION_TTL
      old = SET presence:{user} gateway EX PRESENCE_TTL GET1
      IF old was empty: PUBLISH pres:{user} "online"
    
    watch(viewer, users):                 // only the people on screen2
      SUBSCRIBE pres:{u} FOR EACH u       // once per gateway, shared3
      reply EXISTS presence:{u} FOR EACH u
    
    clean disconnect of the last device:
      DEL presence:{user}4; PUBLISH pres:{user} "offline"
    1. 1One command renews the lease and returns the old value. Empty means the user just came online.
    2. 2The chat list and the open chat. Nobody else pays for this user's status changes.
    3. 3Many devices on one gateway watching one user cost one subscription.
    4. 4A clean exit is instant. A crash waits for the TTL.
    Tested source Go: heartbeat and watch
    Go: heartbeat and watchgo
    // heartbeat renews the registry entry and the presence key. Each is a lease: without a
    // heartbeat it expires, so a crashed gateway's users drop out by themselves. Only a change from
    // offline to online is published.
    func (g *Gateway) heartbeat(ctx context.Context, s *session) error {
      if err := g.rdb.Expire(ctx, g.cfg.sessKey(s.user), g.cfg.SessionTTL).Err(); err != nil {
        return fmt.Errorf("renew session %s: %w", s.key(), err)
      }
      err := g.rdb.SetArgs(ctx, g.cfg.presKey(s.user), g.ID, redis.SetArgs{TTL: g.cfg.PresenceTTL, Get: true}).Err()
      if errors.Is(err, redis.Nil) { // there was no key: the user just came online
        return g.publishPresence(ctx, s.user, "online")
      }
      if err != nil {
        return fmt.Errorf("presence %s: %w", s.user, err)
      }
      return nil
    }
    
    // watch subscribes this gateway to the presence of some users, for one session, and replies
    // with their state now. Clients watch only the people on screen.
    func (g *Gateway) watch(ctx context.Context, s *session, users []string) error {
      for _, u := range users {
        g.mu.Lock()
        first := len(g.watchers[u]) == 0
        if first {
          g.watchers[u] = map[*session]bool{}
        }
        g.watchers[u][s] = true
        g.mu.Unlock()
        if first {
          if err := g.sub.Subscribe(ctx, g.cfg.presChan(u)); err != nil {
            return fmt.Errorf("watch %s: %w", u, err)
          }
        }
        n, err := g.rdb.Exists(ctx, g.cfg.presKey(u)).Result()
        if err != nil {
          return fmt.Errorf("presence of %s: %w", u, err)
        }
        g.reply(s, Frame{T: "presence", User: u, State: map[bool]string{true: "online", false: "offline"}[n == 1]})
      }
      return nil
    }
    

    A small group fans out per member per gateway. Presence is a heartbeat lease, and a gateway subscribes to a user's presence only while someone looks at that user.

    L

    Devices and encryption

    several devices, one history
    needhow
    Same history on each deviceEach device syncs after its own last shown seq.
    Own sends on other devicesRoute to the sender's devices, except the one that sent.
    Read on phone clears the laptopThe read receipt goes to the reader's other devices.
    A new deviceSync history page by page from seq 0.
    End-to-end encryptionKeys per device; the sender encrypts once per device. The server stores ciphertext and routes it unread.
    • With end-to-end encryption, search and history sync move to the devices.
    • Sequence numbers, receipts and routing still work: they never read the body.

    Each device is its own session with its own last shown number. Read marks route to the user's other devices, so badges clear everywhere.

    M

    Failure cases

    what breaks, and why nothing is lost
    eventwhy it is safesaved by
    Gateway crashesMessages were stored first. Devices reconnect anywhere and sync.Store first
    Registry names a dead gatewayPUBLISH reaches 0; the entry is removed and a push queued.Receiver count
    Ack lost, client retriesSame client message ID, same seq, nothing routed again.Unique key
    Live copy and sync copy both arriveThe device drops a seq it already shows.Device dedupe
    8 arrives before 7The device holds 8 and syncs from 7.Gap check
    Redis loses the registryDevices reconnect and re-register; history is in Postgres.Rebuild
    A user crashes, looks onlineThe presence key expires after its TTL.TTL
    N

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1One server2+ gateways, Redis3+ shards by chat4+ sharded Pub/Sub5+ regionsmore load →
    Messages per second against this prompt's demand1001k10k100k1MDemand: average: 231,481 messages per secondDemand: average231,481Demand: 3× peak: 694,444 messages per secondDemand: 3× peak694,444Lab: one hot group: 614 messages per secondLab: one hot group614Lab: 32 one-to-one chats: 4,360 messages per secondLab: 32 one-to-one chats4,360Lab: group deliveries: 5,525 messages per secondLab: group deliveries5,525messages per second, log scale
    stepaddit handlesmove up when you see
    1One server and Postgres; sockets in a map in memory.One machine's sockets. In the lab, 4,400 messages a second, each committed before its ack.Sockets or CPU of one machine run out; one restart drops everyone.
    2Many gateways, a Redis registry and Pub/Sub, as on this sheet.Sockets grow with gateways. Any gateway serves any device.Message writes pass one Postgres primary.
    3Shard messages by conversation, or move them to a wide-column store (X2).Each conversation stays on one shard, so its counter stays local. About 53 shards at this prompt's average.Pub/Sub traffic or registry size passes one Redis.
    4Redis Cluster with sharded Pub/Sub, presence in its own cluster.Channels and keys spread over shards.Users far away see high latency.
    5Regions: gateways near users, each conversation with a home region.Local sockets everywhere; one writer per conversation keeps the order.Top of the ladder.

    Demand from panel C. Lab rates: senders wait for each ack, two gateways over loopback, one Postgres and one Redis on a shared laptop, median of 3 runs of 2 s. Read them as orders of magnitude.

    I start with one server and Postgres. I add gateways with a Redis registry when one machine's sockets run out, and shard messages by conversation when one primary's writes do.

    O

    Follow-ups

    what the interviewer asks next
    questionanswerdeeper
    How do you balance millions of long-lived sockets?Use a layer 4 balancer and least connections. On a deploy, drain a gateway slowly so devices reconnect over minutes, not all at once.B2
    WebSocket, long polling or server-sent events?WebSocket gives one two-way socket. Long polling works through any proxy but costs a request per message batch.B1
    How do you detect a dead gateway sooner?Gateways heartbeat into a membership list; a missed beat removes them. The publish count of 0 is the fallback.X6
    Why not order by timestamps?Clocks on two gateways differ, and a phone's clock can be minutes off. A counter per conversation gives one order.X4
    Where do push notifications come from?Write a notification job to a queue; workers call APNs or FCM and retry. Dedupe on (device, conversation, seq).B6
    Images and voice notes?Upload to object storage with a presigned URL; the message carries the key. The CDN serves the bytes.B9
    How do you store years of history cheaply?Keep recent months in the hot store; move older partitions to cheaper storage and page them on demand.D4
    P

    Drill

    predict, then reveal

    0 of 9 known

    1. Alice is on gateway G1 and Bob on G2. How does Alice's message reach Bob's socket?

    2. Alice's app times out and sends the same message again. Why does Bob see it once?

    3. Why number messages per conversation and not with server timestamps?

    4. Bob's gateway crashes. What happens to the next two messages for him?

    5. A device receives seq 9 but has shown only up to 7. What does it do?

    6. Why are receipts high-water marks and not one row per message?

    7. Why does a crashed user still look online for a while?

    8. Why subscribe to presence lazily?

    9. A group of 10 sends at once. Why did the lab carry only 600 messages a second, against 4,400 for 32 one-to-one chats?

    Q

    Numbers to say

    measured or derived
    demand
    About 231,000 messages a second; 150 M sockets at peak.
    lab, 1:1
    About 4,400 messages a second; p50 10 ms, p99 17 ms.
    lab, group
    600 messages a second on one row; 5,500 deliveries.
    heartbeats
    5 M renewals a second at a 30 s interval.
    presence
    11.6 M events a second pushed to all friends; about 116,000 lazily.
    storage
    2 TB a day at 100 bytes a message.

    Postgres 16 and Redis 8 on an 8-core laptop shared with other runs. Latency is send to shown on another device, over loopback.