Code a chat app with rooms and messages
Open in the interactive app: practice, quiz, mock interview →In one minute
- The object model of a chat app is small: users, conversations, memberships and messages. Most of the design lives in Membership, the join between a user and a conversation, which carries the role, the point where the user joined and how far they have read.
- Give every message a per-conversation sequence number assigned under one lock. Ordering, history paging, unread counts and "seen by" all become integer comparisons against that sequence.
- Read state is a cursor, not a flag per message. "Alice has read up to 57" is one integer per membership, it only moves forward, and it answers both "how many unread" and "who has seen message 50".
- Edits and deletes are state changes on a message, with rules (author only, within a time window, admins may delete). Keep a tombstone instead of removing the row, or sequence gaps and reply references break.
- The shape is ordering plus fan-out. This lesson is the in-process object model; Design WhatsApp and Design Slack cover the distributed architecture, connection gateways and storage. The same membership-with-cursor model reappears in email threads, issue comment streams and collaborative document comments.
Requirements
Functional:
- One-to-one (direct) conversations: creating one between two users who already have one returns the existing conversation.
- Group conversations with a title, an owner and admins. Admins add members; a new member sees messages sent after they joined, not the earlier history.
- Members send text messages, read history newest first in pages, edit their own messages within 15 minutes and delete their own messages. Group admins may delete anyone's message.
- Each member has a read position. The app shows unread counts per conversation and, in groups, who has seen a given message.
Non-functional:
- Every member sees a conversation's messages in the same order.
- Unread counts and read receipts never go backwards, even if read events arrive out of order from two devices.
- Rules (who can edit, delete, add) are enforced in the domain model, not in the UI.
- Time comes from an injected clock, so the edit window is testable.
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
- User is an id and a display name. It holds no chat state; everything about a user in a conversation lives on the membership.
- Conversation has an id, a kind (direct or group), a title for groups, the last assigned sequence number and its members. It is the consistency boundary: one lock per conversation serialises sequence assignment.
- Membership links a user to a conversation with a role (owner, admin, member),
joinedAfterSeq(the last sequence at the moment they joined) andreadUpTo(their read cursor). Visibility and unread counts are computed from these two numbers. - Message has the conversation id, sequence, sender, body, sent time and a status (sent, edited, deleted). The sequence and sender never change; the body changes only through
editand is blanked bydelete. - ConversationPolicy answers permission questions such as "may this user add members" or "may this user delete that message". Direct and group conversations have different policies.
- ChatService is the façade that clients call. It looks up the conversation, checks the policy, mutates state under the conversation lock and emits events.
- MessageListener receives
MessageSent,MessageEdited,MessageDeletedandReadAdvancedevents for delivery to devices. Clock supplies time.
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 ..|> ConversationPolicyKey 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 58Design patterns used and why
- Façade:
ChatServiceis the only entry point. Clients never touch aConversationdirectly, so every mutation passes the same membership and policy checks and the same lock. - Strategy for permissions.
DirectPolicyrefusesaddMemberand lets only the author delete;GroupPolicylets admins add and delete. A future "announcement channel" where only admins post is a third policy, with no change tosend. - State for the message lifecycle, kept as an enum with guarded transitions: sent to edited, sent or edited to deleted, and nothing out of deleted. A full class per state is more machinery than three states need; see the State lesson for when it pays off.
- Observer for delivery. The service emits events; a gateway adapter pushes them to devices, a notification adapter sends pushes to offline users, and a search indexer consumes the same stream. Listeners run after the lock is released, so a slow push provider cannot block senders. See Design Pub Sub System for keeping that fan-out non-blocking.
- Factory method for conversations:
direct(a, b)normalises the pair, consults the index and returns the existing conversation or builds a new one withDirectPolicy. Callers never construct conversations themselves, which is what makes the "one direct thread per pair" rule enforceable.
Concurrency and edge cases
- Two sends at once. Sequence assignment and append must be one step under the conversation's lock. Without it, two senders read
lastSeq = 57and both write 58. A per-conversation lock (not a global one) lets busy conversations proceed in parallel. - Out-of-order read events. A phone reports "read up to 60" and a laptop then reports "read up to 55". Taking
max(current, new)makes the cursor monotonic, so the second event is a no-op. Clamp tolastSeqso a buggy client cannot mark future messages read. - Joining mid-conversation. The new member's
joinedAfterSeqis the conversation'slastSeqat join time. History and unread counts ignore anything at or below it. Their read cursor starts there too, which also keeps them out of "seen by" for old messages. - Edit racing delete. The author edits while an admin deletes. Under the lock, whichever runs second sees the other's result; an edit on a deleted message fails. The outcome is consistent, and the error tells the client to refresh.
- Edit window. Compare
clock.now() - sentAtagainst the window. StoresentAtfrom the server clock at send time; never trust a client timestamp for a permission check. - Duplicate direct conversations. Two users tap "message" on each other at the same moment. The pair index is checked and written under one lock (or a unique constraint on
(min(a,b), max(a,b))in the database), so both calls return the same conversation.
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
- People need the same order on every device, so the conversation assigns sequence numbers at one point instead of trusting client clocks.
- One point of assignment means a lock, so the lock is per conversation to keep unrelated chats independent.
- 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.
- 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.
- Delivery is slow and fans out, so it runs after the state change through listeners, never inside the lock.
Extensibility
- Threads and replies. Add
parentSeqto a message. A thread is a filtered view; the per-conversation sequence stays the single order. Thread-level read cursors are a second cursor on the membership keyed by parent. - Reactions. A map from emoji to a set of user ids on the message, changed under the conversation lock and emitted as an event. It does not take a new sequence number, so it does not disturb ordering or unread counts.
- Announcement channels. A new policy where only admins may send.
sendasks the policy, as delete and add already do. - Multi-device. Read cursors are per user, not per device, so reading on the laptop clears the phone's badge. Delivery state per device is a transport concern, covered in Design WhatsApp.
Leadership lens
- Build first: sequence assignment under a per-conversation lock, the membership with its two cursors, and a test where two devices send and read out of order. Every visible bug in chat (messages out of order, badges that come back) traces to one of these.
- Ask in review: where is the sequence assigned; what happens when read events arrive out of order; what does a member who joined yesterday see; does a delete leave a tombstone.
- Keep permission rules in the domain policy and give the client team a read-only
capabilities(conversation)call, so the UI shows the right buttons without duplicating the rules. - Cost driver: read receipts and typing indicators produce more events than messages. Budget for them, batch them, and drop typing indicators first under load.
Check yourself
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.
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.
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.
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
- WhatsApp System Design (Gaurav Sen): connections, receipts and group messaging, the distributed side of this model.
- Keith Adams on the Architecture of Slack (InfoQ): how a production chat system stores channels and messages and delivers them.
Free resources
- Real-time Messaging (Slack Engineering): how Slack's channel servers order messages and push them to clients.
- How Discord Stores Trillions of Messages (Discord): message ids, partitioning by channel and time buckets, and the read path.
- Matrix specification: receipts: an open chat protocol's definition of read receipts as "read up to" markers.
- State (Game Programming Patterns, Robert Nystrom): when an enum with guarded transitions is enough and when a state machine needs more.
- Explicit Locking (PostgreSQL docs): the row lock behind per-conversation sequence assignment.