Engineering Atlas › Low-Level Design

Code a chat app with rooms and messages

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: network transport, offline sync, push notifications, media, end-to-end encryption and search. Name them and point at the system design lessons; the interviewer wants to see the model and the rules.

Entities and relationships

Relationships: a conversation is composed of its messages and memberships (they do not exist without it). A membership references a user but does not own it; a user can have thousands of memberships. Direct conversations are found through an index keyed by the sorted pair of user ids, which is how "open chat with Bob" returns the existing thread instead of creating a second one.

Resist a DirectConversation and GroupConversation hierarchy: sending is identical, and a policy object carries the rules that differ.

Class diagram

classDiagram
    class ChatService {
      -conversations: Map~ConvId, Conversation~
      -directIndex: Map~PairKey, ConvId~
      +direct(a, b) ConvId
      +group(owner, title, members) ConvId
      +addMember(actor, conv, user)
      +send(user, conv, text) int
      +edit(user, conv, seq, text)
      +delete(user, conv, seq)
      +history(user, conv, limit) Message[]
      +markRead(user, conv, seq)
      +unread(user, conv) int
      +seenBy(conv, seq) UserId[]
    }
    class Conversation {
      +id: ConvId
      +kind: Kind
      +title: string
      -lastSeq: int
      -messages: Message[]
      -members: Map~UserId, Membership~
    }
    class Membership {
      +userId: UserId
      +role: Role
      +joinedAfterSeq: int
      +readUpTo: int
    }
    class Message {
      +seq: int
      +sender: UserId
      +body: string
      +sentAt: int
      +status: Status
    }
    class ConversationPolicy {
      <<interface>>
      +canAdd(actor) bool
      +canDelete(actor, msg) bool
    }
    class MessageListener {
      <<interface>>
      +onEvent(event)
    }
    class Clock {
      <<interface>>
      +now() int
    }
    ChatService "1" *-- "many" Conversation
    Conversation "1" *-- "many" Membership
    Conversation "1" *-- "many" Message
    Conversation --> ConversationPolicy
    ChatService --> MessageListener
    ChatService --> Clock
    DirectPolicy ..|> ConversationPolicy
    GroupPolicy ..|> ConversationPolicy

Key flows

Sending assigns the sequence and stores the message under the conversation lock, then notifies listeners outside it. Reading advances a cursor and emits a receipt only when the cursor actually moved.

sequenceDiagram
    participant A as Alice device
    participant S as ChatService
    participant C as Conversation
    participant L as MessageListener
    participant B as Bob device
    A->>S: send(alice, c7, "lunch?")
    S->>C: lock, check membership
    C->>C: lastSeq = 58, store message 58
    C->>C: alice.readUpTo = 58, unlock
    S-->>A: seq 58
    S->>L: MessageSent(c7, 58)
    L->>B: deliver message 58
    B->>S: markRead(bob, c7, 58)
    S->>C: lock, readUpTo = max(52, 58), unlock
    S->>L: ReadAdvanced(c7, bob, 58)
    L->>A: Bob has seen up to 58

Design patterns used and why

Concurrency and edge cases

Down the stack

Layer 1The contract

send returns a sequence number one greater than the previous message in that conversation, and every member's history shows messages in that order. history returns only messages with seq > joinedAfterSeq for the caller. readUpTo never decreases and never exceeds lastSeq. unread is the number of visible, non-deleted messages from other senders after the cursor. A failed operation (not a member, not allowed, outside the window) changes nothing.

Layer 2The mechanism

Each conversation keeps messages in an array indexed by seq - 1, so lookup by sequence is O(1) and "last 50" is a slice. In a database this is a table keyed by (conversation_id, seq), clustered on that key so a history page is one range scan; INSERT ... SELECT last_seq + 1 inside a transaction that locks the conversation row (SELECT ... FOR UPDATE) assigns the sequence. Memberships are a table keyed by (conversation_id, user_id) with role, joined_after_seq and read_up_to, plus a secondary index on user_id for the inbox list. The read path for an inbox computes a cheap unread approximation, last_seq - read_up_to, from those two integers without touching messages; an exact count that excludes your own and deleted messages is either computed on open or kept as a per-membership counter maintained on send and delete. The practice computes it exactly by scanning, which is fine in memory and too slow for a 10,000-conversation inbox.

Layer 3The machine

A text message row is small: two 8-byte ids, an 8-byte sequence, an 8-byte timestamp, a status byte and a body that averages under 100 bytes, so roughly 140 bytes, and a 16 KB InnoDB page holds about 100 of them. A history page of 50 messages is then one or two page reads, served from the buffer pool for an active conversation. The conversation lock is the contended resource: an uncontended mutex costs tens of nanoseconds and a database row lock held for a sub-millisecond transaction allows a few thousand sends per second in one conversation, far more than any human group produces. Delivery is where bytes multiply: one message to a 500-member group is 500 WebSocket frames of a few hundred bytes each, which is why events leave the lock first and fan out on other threads. Read receipts are the hidden load: every member reading every message generates as many events as messages times members, so clients batch them (send "read up to N" once per second, not per message), and the cursor model makes batching lossless.

Why it's built this way

  1. People need the same order on every device, so the conversation assigns sequence numbers at one point instead of trusting client clocks.
  2. One point of assignment means a lock, so the lock is per conversation to keep unrelated chats independent.
  3. Read events are frequent and arrive out of order from several devices, so read state is a monotonic cursor, which makes duplicates and reordering harmless and batching free.
  4. Members join at different times and messages get edited or deleted, so visibility is computed from the membership's numbers and messages become tombstones rather than disappearing.
  5. Delivery is slow and fans out, so it runs after the state change through listeners, never inside the lock.

Extensibility

Leadership lens

Check yourself

  1. Why store a read cursor per membership instead of a read flag per message per user?

    Show answer

    A cursor is one integer per member that only moves forward, so out-of-order or duplicate read events are harmless and unread counts are a subtraction. Flags need a row per message per member and break under reordering.

  2. A member joins a group at sequence 120. What do they see, and what is their unread count right after joining?

    Show answer

    They see messages after 120 only, and their unread count is zero, because their cursor starts at the join point.

  3. Why keep a deleted message as a tombstone instead of removing it?

    Show answer

    Removing it would leave a gap in sequences and orphan replies and receipts that reference it. A tombstone keeps the order and the references intact while hiding the content.

  4. Why notify listeners after releasing the conversation lock?

    Show answer

    Delivery can be slow (network, push providers). Running it inside the lock would serialise every sender in that conversation behind the slowest device.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →