Design WhatsApp
Patterns, Technologies, and Concepts
Key Takeaways
Requirements
You demonstrated strong understanding of non-functional requirements for a messaging system, clearly articulating latency targets, delivery guarantees, fault tolerance, and message retention.
-
For real-time messaging systems, under 500ms is a solid and realistic latency target to cite. It reflects the threshold where users start to notice lag in a conversation, making it a meaningful benchmark rather than an arbitrary number.
-
Message retention policy is an important non-functional requirement for messaging systems. Storing messages only as long as necessary reduces storage costs and can have privacy and compliance benefits, especially for systems like WhatsApp that favor end-to-end encryption with minimal server-side storage.
Core Entities
You demonstrated a strong grasp of entity modeling for WhatsApp, correctly identifying the core entities and recognizing that 1:1 and group chats can share the same data model.
- Modeling 1:1 and group chats as the same entity (e.g., a Chat table with a type flag or participant count) keeps your schema clean and avoids duplicating logic. A chat is just a container for messages with one or more participants, so there is no need for separate tables.
API
You initially missed specifying transport mechanisms for real-time communication, but quickly corrected this in your second attempt by properly distinguishing client-to-server and server-to-client flows using WebSockets.
-
In a real-time messaging system, message delivery endpoints must be server-pushed, not client-polled. WebSockets allow the server to emit events like ‘newMessage’ directly to the client whenever a message arrives, without the client needing to ask for it. Always label which direction each API call flows.
-
User identity should always come from an authentication header like a JWT token, not from the request body. Putting the sender ID in the body lets malicious clients spoof other users’ identities. The server extracts who you are from the verified token, not from what you claim in the payload.
-
For messaging APIs, combining message and attachment into a single sendMessage call with an optional attachments field is cleaner than two separate endpoints. This ensures the message and its media are treated as one atomic unit, simplifying both client logic and server handling.
High Level Design
You demonstrated strong instincts throughout this section and consistently incorporated feedback well, with the main gaps being around subtle but important details like cross-server WebSocket delivery, the difference between deleting shared messages vs. per-recipient inbox entries, and keeping your diagrams consistent with your verbal explanations.
-
WebSocket connections are stateful and tied to a single server. If two users are connected to different chat servers, the sending server cannot push directly to the recipient. You need a message broker like Redis Pub/Sub or a shared inbox table so the correct server can deliver the message. Always flag this as a simplifying assumption when you assume both users share the same server.
-
In a chat system, keep the shared message record and the per-recipient delivery state separate. The Message table stores the message content permanently for chat history. A separate Inbox or MessageDelivery table tracks delivery status per recipient (for example a pending or delivered field). When a user acknowledges receipt, you mark or delete the inbox row, not the shared message.
-
When designing offline message delivery, store a delivery status field (like pending or delivered) on the per-recipient inbox row rather than relying only on row deletion after ACK. This makes the delivery state explicit in your data model and easier to query, for example fetching all pending messages for a user who just reconnected.
-
For media uploads, use presigned URLs so clients upload and download directly to S3 without proxying through your chat server. The flow is: client asks chat server for a presigned URL, client uploads directly to S3, then sends the S3 object reference as part of the message. Store the object key or reference in your message model, not a temporary presigned URL, so you can generate fresh download URLs on demand later.
Deep Dives
You demonstrated strong progression across attempts, moving from a solid pub/sub foundation to a well-rounded multi-device delivery system, with your main gap being in-flight deduplication and how the per-server socket map gets maintained on connect and disconnect.
-
Reconnection-based backfill alone does not prevent duplicate delivery. If two servers both receive a pub/sub event for the same user, both may deliver to the same device. The fix is to assign each message a stable messageId and have the client drop any message whose ID it has already seen. This is called client-side deduplication and it handles in-flight duplicates that reconnect logic cannot catch.
-
Per-device inbox rows should only be deleted after that specific device sends an ACK back to the server. This means an offline device keeps its inbox row intact until it comes back online, reconnects, fetches undelivered messages, and confirms receipt. This ties the server-side delivery tracking and client-side deduplication together cleanly.
-
Each chat server should maintain an in-memory map of (userId, deviceId) to socketId. When a WebSocket connection opens, the server adds that entry to the map. When it closes or a heartbeat times out, the server removes it. This local map is how the server knows which physical socket to write to when it receives a pub/sub event for a user connected to it.
-
A clients table mapping deviceId to userId gives you a persistent registry of all known devices for a user. This is what lets the system fan out a message to every device at send time, not just the ones that happen to be online, because you can look up all deviceIds for a userId and create an inbox row for each one before publishing the delivery event.