Design a Chat System (WhatsApp LLD)

The object design under the messaging giant: conversations, the message delivery state machine, local-first storage, and sync after offline.

LLDOODstate-machinesync

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:

  1. 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.
  2. 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.
  3. 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:

  • Conversation is 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.id is 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 seq per 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.

Message delivery (the ticks)time O(1) per eventspace O(states)
ackdeliveropenPendSentDelivRead
events:ackdeliveropen

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.

state = Pend
  • 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 SyncEngine drains 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

Think it through: Design a Chat System (LLD)LLD Classic — offline-first0/5 stages

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. 1

    Find the hard half

    Server or client — which side is the real design problem here, and why?

  2. 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. 3

    Model the ticks as a state machine

    Grey clock, ✓, ✓✓, blue ✓✓ — what are these, precisely?

    unlocks after the stage above
  4. 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. 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

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

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++

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

Check yourself0/3 answered

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.

Practice ladder: Fan-out, ordering & history0/4 solved

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

  1. An ordered log you can move through — a single conversation's timeline.

Core — deliver to many, in time order

  1. Fan a post out to followers' feeds — the same push as delivering to room members.

  2. Fetch the value as of a timestamp — message history ordered by time.

Stretch — exactly-once under retries

  1. Suppress a duplicate inside a window — idempotent delivery when the client retries.