The real-world analogy: desk logs and courier outbox trays
Imagine working in an office with no phone service, sending messages via hand-delivered mail:
- The Notebook Log (Local-First Store): You have a physical notebook on your desk. When you write a letter to Asha, you first record it line-by-line in your notebook. If you lose power or your courier is busy, the text is still written down locally inside your book. The notebook is your local database—the only thing you read when checking message history.
- The Outbox Tray (Sync Engine): Beside your notebook is a wire tray labeled "OUTBOX." You place a copy of your letter in the tray. Even if you walk away or go to sleep, a mail runner (Sync Engine) will periodically sweep the outbox, pick up the envelope, deliver it to the post office (Server), and stamp your notebook copy with a "SENT" checkmark.
- Certified Return Slips (Receipt Ticks): The post office returns slips as letters are handled:
- Asha's mailroom receives it: Delivered (Double Tick).
- Asha opens and reads it: Read (Blue Tick).
Tying message identity to a client-generated UUID ensures that if the courier retries delivery of the same letter after getting lost, the recipient's mailroom checks the registration ID and rejects the duplicate cleanly.
Scope it first
"The object design of a WhatsApp-like client and its server-side session layer: 1:1 and group conversations, message states (sent / delivered / read), offline users syncing on return, typing indicators. The distributed infrastructure is the HLD doc — here we design the classes that ride on it. OK?"
That split is itself the lesson: the HLD answered how a billion messages move; this answers what a message is — and chat is the rare LLD where the client is the harder half, because phones are offline-first databases with a UI.
UML Class Diagram
The non-obvious modeling decisions to narrate:
Conversationis abstract; group vs direct are subclasses — groups add membership rules and admin powers; the message flow stays identical, so all delivery code works on the base type (polymorphism earning rent).Message.idis a client-generated UUID — created before any network contact, so retries dedup server-side and the message has identity even while offline. The single most load-bearing field in the design (the WhatsApp HLD's dedup key, born here).- Content as an interface (text/media/location/reply), not a blob of nullable fields — adding "polls" is a new content type, not a schema scar. Media content holds a URL + local cache path, never bytes (blobs ride elsewhere).
- Ordering by server-assigned
seqper conversation, not by clock — phones' clocks lie (sequence numbers, not timestamps); the client sorts and gap-detects on seq.
The message state machine (the ticks)
Every sent message walks one state machine, and every transition has a trigger and an owner.
Step through a message through ack → deliver → open and watch the ticks advance.
1/4Start in Pend. Each event is handled by the current state — the State pattern moves this branching out of one giant switch and into the state objects themselves.
- PENDING — exists only locally: rendered in the UI instantly (optimistic UI), sitting in the outbox (below). One grey clock.
- SENT — the server has it durably; one tick. Note what this is not: no claim about the recipient.
- DELIVERED — the recipient's device acked; two ticks. In groups: per-participant fan-in — the aggregate shows when all have it, the detail view shows each (
Map<participant, DeliveryState>— the state machine runs per recipient). - READ — a product event (chat opened), not a transport event; gated by privacy settings. Keeping transport states and product states distinct in your model is a senior tell.
Status updates arrive as tiny system messages flowing back along the same pipes — the Observer pattern live: the Message mutates, the conversation view re-renders the tick.
The client is a database (outbox + sync)
The phone must work in a tunnel:
- Local-first store: every conversation's messages live in a local DB (SQLite); the UI reads only local data — network arrival writes to the store, and the store notifies the UI (Observer again). This one decision makes offline reading, instant search and fast startup fall out for free.
- The outbox pattern: sending = append to local store (PENDING) + enqueue in a persistent outbox. A background
SyncEnginedrains it — retries with backoff across app restarts; the UUID makes every retry idempotent. Kill the app mid-send; the message still goes. - Sync on reconnect: per conversation, the client tracks the last seen
seq(a cursor); reconnection asks "everything after cursor" — exactly the offline-inbox drain from the server's perspective, and the Google Drive change-log pattern from the client's. Gap in seqs mid-session → same fetch. One mechanism heals both. - Typing indicators are the anti-message: fire-and-forget, never stored, TTL'd (3 s) so a dropped "stopped typing" packet self-heals — explicitly contrasting them with messages shows you classify data by durability need (the Uber location argument).
Think it through like the interview
PROBLEMDesign the classes for a WhatsApp-like client and its session layer: 1:1 and group chats, sent/delivered/read states, offline users syncing on return.
- 1
Find the hard half
“Server or client — which side is the real design problem here, and why?”
- 2
Give the message identity before the network
“Who generates the message id — client or server? This one decision carries the design.”
unlocks after the stage above - 3
Model the ticks as a state machine
“Grey clock, ✓, ✓✓, blue ✓✓ — what are these, precisely?”
unlocks after the stage above - 4
Make the client a database
“The UI must never block on the network, yet no message may be lost. What architecture squares that?”
unlocks after the stage above - 5
Sync = cursors, not diffs
“Asha was offline for an hour. How does her phone catch up — and how does the same trick fix mid-session gaps?”
unlocks after the stage above
Implementation
Below are complete implementations featuring message entities, local store mocks, and sync outboxes.
Python
import time
import queue
import uuid
import threading
from abc import ABC, abstractmethod
from enum import Enum
from typing import Dict, List, Set, Optional
class MessageStatus(Enum):
PENDING = 1
SENT = 2
DELIVERED = 3
READ = 4
class Message:
def __init__(self, sender_id: str, conversation_id: str, content: str):
self.message_id = str(uuid.uuid4()) # Client-generated UUID
self.sender_id = sender_id
self.conversation_id = conversation_id
self.content = content
self.status = MessageStatus.PENDING
self.seq: Optional[int] = None # Assigned by server
class Conversation(ABC):
def __init__(self, conversation_id: str, participants: List[str]):
self.conversation_id = conversation_id
self.participants = participants
self.messages: List[Message] = []
self.lock = threading.Lock()
class DirectChat(Conversation):
pass
class GroupChat(Conversation):
def __init__(self, conversation_id: str, participants: List[str], admin_id: str):
super().__init__(conversation_id, participants)
self.admins: Set[str] = {admin_id}
class LocalMessageStore:
def __init__(self):
self.store: Dict[str, List[Message]] = {}
self.lock = threading.Lock()
def append(self, message: Message):
with self.lock:
if message.conversation_id not in self.store:
self.store[message.conversation_id] = []
self.store[message.conversation_id].append(message)
def get_messages(self, conversation_id: str) -> List[Message]:
with self.lock:
return list(self.store.get(conversation_id, []))
class SyncEngine:
def __init__(self, store: LocalMessageStore, send_to_server_callback):
self.outbox = queue.Queue()
self.store = store
self.send_to_server = send_to_server_callback
self.running = False
self.lock = threading.Lock()
def submit_message(self, msg: Message):
self.store.append(msg)
self.outbox.put(msg)
def start(self):
self.running = True
self.worker = threading.Thread(target=self._drain_outbox, daemon=True)
self.worker.start()
def _drain_outbox(self):
while self.running:
try:
msg = self.outbox.get(timeout=1.0)
except queue.Empty:
continue
success, server_seq = self.send_to_server(msg)
if success:
msg.status = MessageStatus.SENT
msg.seq = server_seq
else:
# Retry with backoff
time.sleep(2)
self.outbox.put(msg)
self.outbox.task_done()
Java
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
enum MessageStatus { PENDING, SENT, DELIVERED, READ }
class Message {
String messageId = UUID.randomUUID().toString(); // Client-generated UUID
String senderId;
String conversationId;
String content;
MessageStatus status = MessageStatus.PENDING;
Integer seq = null; // Assigned by server
Message(String senderId, String conversationId, String content) {
this.senderId = senderId;
this.conversationId = conversationId;
this.content = content;
}
}
abstract class Conversation {
String conversationId;
List<String> participants;
List<Message> messages = new ArrayList<>();
final ReentrantLock lock = new ReentrantLock();
Conversation(String id, List<String> participants) {
this.conversationId = id;
this.participants = participants;
}
}
class DirectChat extends Conversation {
DirectChat(String id, List<String> participants) { super(id, participants); }
}
class GroupChat extends Conversation {
Set<String> admins = new HashSet<>();
GroupChat(String id, List<String> participants, String adminId) {
super(id, participants);
this.admins.add(adminId);
}
}
class LocalMessageStore {
private final Map<String, List<Message>> store = new ConcurrentHashMap<>();
public void append(Message msg) {
store.computeIfAbsent(msg.conversationId, k -> new CopyOnWriteArrayList<>()).add(msg);
}
public List<Message> getMessages(String conversationId) {
return store.getOrDefault(conversationId, Collections.emptyList());
}
}
class SyncEngine {
private final BlockingQueue<Message> outbox = new LinkedBlockingQueue<>();
private final LocalMessageStore store;
private final ServerConnector connector;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
interface ServerConnector {
int send(Message msg) throws Exception;
}
SyncEngine(LocalMessageStore store, ServerConnector connector) {
this.store = store;
this.connector = connector;
}
public void submitMessage(Message msg) {
store.append(msg);
outbox.add(msg);
}
public void start() {
executor.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Message msg = outbox.take();
try {
int seq = connector.send(msg);
msg.status = MessageStatus.SENT;
msg.seq = seq;
} catch (Exception e) {
Thread.sleep(2000); // Backoff retry
outbox.add(msg);
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
});
}
}
C++
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <queue>
#include <mutex>
#include <thread>
#include <chrono>
#include <memory>
#include <stdexcept>
enum class MessageStatus { PENDING, SENT, DELIVERED, READ };
struct Message {
std::string messageId; // Client-generated UUID
std::string senderId;
std::string conversationId;
std::string content;
MessageStatus status = MessageStatus::PENDING;
int seq = -1; // Server-assigned sequence
Message(std::string sender, std::string conv, std::string text)
: senderId(sender), conversationId(conv), content(text) {
messageId = "client-uuid-" + std::to_string(std::chrono::system_clock::now().time_since_epoch().count());
}
};
class Conversation {
public:
std::string conversationId;
std::vector<std::string> participants;
std::vector<std::shared_ptr<Message>> messages;
std::mutex mtx;
Conversation(std::string id, const std::vector<std::string>& p) : conversationId(id), participants(p) {}
virtual ~Conversation() = default;
};
class DirectChat : public Conversation {
public:
DirectChat(std::string id, const std::vector<std::string>& p) : Conversation(id, p) {}
};
class GroupChat : public Conversation {
public:
std::unordered_set<std::string> admins;
GroupChat(std::string id, const std::vector<std::string>& p, std::string admin) : Conversation(id, p) {
admins.insert(admin);
}
};
class LocalMessageStore {
private:
std::unordered_map<std::string, std::vector<std::shared_ptr<Message>>> store;
std::mutex mtx;
public:
void append(std::shared_ptr<Message> msg) {
std::lock_guard<std::mutex> lock(mtx);
store[msg->conversationId].push_back(msg);
}
std::vector<std::shared_ptr<Message>> getMessages(const std::string& convId) {
std::lock_guard<std::mutex> lock(mtx);
return store[convId];
}
};
class SyncEngine {
private:
std::queue<std::shared_ptr<Message>> outbox;
std::shared_ptr<LocalMessageStore> store;
std::mutex mtx;
std::thread workerThread;
bool running = false;
int sendToServer(std::shared_ptr<Message> msg) {
return 999;
}
void drainOutbox() {
while (running) {
std::shared_ptr<Message> msg;
{
std::lock_guard<std::mutex> lock(mtx);
if (outbox.empty()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
continue;
}
msg = outbox.front();
outbox.pop();
}
try {
int seq = sendToServer(msg);
msg->status = MessageStatus::SENT;
msg->seq = seq;
} catch (...) {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::lock_guard<std::mutex> lock(mtx);
outbox.push(msg);
}
}
}
public:
SyncEngine(std::shared_ptr<LocalMessageStore> s) : store(s) {}
~SyncEngine() {
running = false;
if (workerThread.joinable()) workerThread.join();
}
void submitMessage(std::shared_ptr<Message> msg) {
store->append(msg);
std::lock_guard<std::mutex> lock(mtx);
outbox.push(msg);
}
void start() {
running = true;
workerThread = std::thread(&SyncEngine::drainOutbox, this);
}
};
Interactive Quiz
1.
2.
3.
Walk a scenario
Asha (in a lift, no signal) types "running late" to the group: UUID m-91f3 created → local store append (PENDING) → outbox → UI shows it instantly, grey clock. Signal returns: SyncEngine drains — server acks, assigns seq 412 → SENT ✓. Server fans out (HLD's job); Rahul's phone acks → his entry in the delivery map flips; when the last member's device acks → DELIVERED ✓✓; reads trickle in per privacy settings. Meanwhile Asha's phone had missed seqs 410–411 (sent while she was offline) — the same reconnect pulled them by cursor, slotting them above hers by seq. Every arrow in that story is a class from the diagram doing one job.
Q&A
Practice — level up
A chat system is ordered history plus fan-out delivery: append a message, push it to everyone in the room, and let anyone scroll back in order. These drills rehearse each move.
Climb in order — every rung assumes the one above it. Solve on LeetCode, then tick it here; progress is saved on this device.
Warm-up — ordered, navigable history
- Design Browser HistoryMedium
An ordered log you can move through — a single conversation's timeline.
Core — deliver to many, in time order
- Design TwitterMedium
Fan a post out to followers' feeds — the same push as delivering to room members.
Fetch the value as of a timestamp — message history ordered by time.
Stretch — exactly-once under retries
Suppress a duplicate inside a window — idempotent delivery when the client retries.