Design WhatsApp (Chat)
Real-time chat on the client: WebSocket transport, message ordering and dedupe, optimistic send with delivery states, an offline outbox, and a message list that scrolls to bottom correctly.
Published · by Frontend Masters India
"Design WhatsApp" looks like a list of bubbles. The hard part is everything around the bubble: how a message gets to the other person in real time, how you show it as sending then sent then read, what happens when the network drops mid-send, and how a list of ten thousand messages stays smooth while always sticking to the bottom. Interviewers reach for this prompt because it forces you to reason about transport, local state, and offline all at once.
1. Scope it first
Ask before you draw:
- One-to-one, groups, or both? Groups change fan-out and read receipts (per-member, not a single tick).
- Do we need delivery and read receipts? That's the single/double/blue tick model and it drives a lot of client state.
- Offline support? Can the user open the app on a plane and read history, queue messages, and have them send on reconnect?
- How much history? Months of messages means pagination upward and local persistence, not "load it all."
Reasonable assumptions to state: one-to-one and small groups, full send/delivered/read states, offline-first with a local store, and history loaded in pages.
2. Data and component model
A conversation is a list of messages, each with a stable id, an author, a timestamp, body, and a status. Keep status separate from content so you can update it without touching the body.
type Message = {
id: string; // server id once acked
clientId: string; // generated on the device before send
chatId: string;
authorId: string;
body: string;
sentAt: number; // client clock at compose time
status: "sending" | "sent" | "delivered" | "read" | "failed";
};<ChatScreen>
<ChatHeader /> // name, presence, typing
<MessageList> // virtualized, sticks to bottom
<MessageBubble /> // body + status ticks
</MessageList>
<Composer /> // input, send button, retry on failed
</ChatScreen>The clientId is the important one. You generate it on the device the moment the user hits send, before the server has ever seen the message. It's how you match the optimistic bubble to the server's acknowledgement later.
3. Transport: WebSocket, not polling
For live chat you want a persistent connection so the server can push to you. Long-polling works but you pay a request setup cost per cycle and you fight latency. A WebSocket gives you a duplex channel: send messages up, receive messages and receipts down.
const ws = new WebSocket("wss://chat.example.com");
ws.onmessage = (e) => dispatch(JSON.parse(e.data));
ws.onclose = () => scheduleReconnect(); // backoff, then resubscribeThe connection will drop. Phones sleep, tunnels die, wifi hands off to cellular. So treat the socket as best-effort and build reconnect with exponential backoff plus jitter. On reconnect, you fetch anything you missed by asking the server for messages after your last known id.
Don't trust the socket as your source of truth for "did this send." The flow is: send over the socket, wait for an explicit ack carrying the server id, and only then mark the message sent.
4. Optimistic send and the delivery states
When the user sends, the message should appear instantly. You don't wait for the server.
function send(body) {
const msg = {
clientId: uuid(), chatId, body,
authorId: me, sentAt: Date.now(), status: "sending",
};
store.add(msg); // shows immediately
outbox.enqueue(msg); // durable, survives reload
trySend(msg);
}The status then walks forward as signals come back:
- sending the moment it's in the list.
- sent when the server acks with a real id. Now you reconcile: replace the optimistic row's
clientIdmatch with the server id. - delivered when the recipient's device acks receipt.
- read when they open the chat.
If trySend fails or times out, mark it failed and show a retry affordance. Never let a message sit silently in sending forever.
5. Ordering and dedupe
Messages can arrive out of order, and the same message can arrive twice (you reconnect and refetch a window that overlaps what you already have). Two rules keep the list sane.
For dedupe, key by id. When a server message arrives, check if you already have a row with that server id, or an optimistic row whose clientId matches the clientId the server echoed back. If so, merge instead of appending.
For ordering, don't sort by client clock alone, because two devices disagree on time. Sort by the server's sequence number or server timestamp, falling back to sentAt only for messages still sending. The pending bubbles stay pinned at the bottom until they get a real sequence.
6. The message list that sticks to the bottom
This is the part people get wrong. A chat list scrolls to the newest message at the bottom, and it has to keep doing the right thing in three situations:
- New message arrives while you're at the bottom → scroll to it.
- New message arrives while you've scrolled up reading old stuff → do not yank them down. Show a "new messages" jump button instead.
- You load older history at the top → the scroll position must not jump.
Track whether the user is "near bottom" before each insert, then decide:
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
insertMessage(msg);
if (nearBottom) el.scrollTop = el.scrollHeight; // follow
else showNewMessagesPill(); // don't disturbFor loading older messages upward (prepending), you must preserve the visual position. Capture scrollHeight before the prepend and restore the offset after, otherwise the content jumps under the user's eyes:
const before = el.scrollHeight;
prependOlderPage(messages);
el.scrollTop += el.scrollHeight - before;Once the list is long, virtualize it. Chat rows have variable heights (a one-word reply vs a paragraph vs an image), so you need dynamic measurement. Virtualization plus bottom-sticking plus prepend-preservation is the fiddliest combination in this whole design, and saying that out loud shows you've actually built one.
7. Offline outbox and local persistence
Persist both the message history and the outbox to IndexedDB. IndexedDB over localStorage because you're storing structured records, possibly thousands, and localStorage is synchronous and tiny.
On launch, hydrate the list from IndexedDB so the chat paints instantly without a network round trip. The outbox is a durable queue of messages that haven't been acked. On reconnect, drain it in order, retrying each with backoff. Because every queued message carries a clientId, retries are idempotent: if the server already saw it, it returns the same server id and you just reconcile rather than create a duplicate.
[compose] → IndexedDB outbox → try send
↳ ack received → mark sent, remove from outbox
↳ offline/fail → keep in outbox, retry on reconnect8. Presence and typing
Typing indicators are noisy, so debounce them: send a "typing" event when the user starts, and a "stopped" after ~3 seconds of no input or on send. Presence (online/last seen) comes over the same socket. Treat both as ephemeral, never persist them, and let them expire if the socket goes quiet.
What the interviewer will push on
- "How do you guarantee a message isn't lost or duplicated?" Client-generated
clientIdmakes sends idempotent; the server acks with a stable id; dedupe on receive by id andclientId. - "The socket dropped for 30 seconds, then came back. What happens?" Backoff reconnect, then fetch everything after the last known server sequence; the outbox drains queued sends; dedupe absorbs overlap.
- "Group chat read receipts?" Track read state per member, not a single boolean; aggregate to "read by all" for the tick.
- "Two devices, same account?" Each device has its own socket and last-seen cursor; the server fans the message to all of them; reconciliation by id keeps every device consistent.
- "How do you keep memory bounded over months of history?" Page history in from IndexedDB, virtualize the rendered rows, and drop far-off-screen messages from React state while keeping them on disk.
The one-paragraph recap
WhatsApp on the client is a persistent WebSocket with backoff reconnect, optimistic sends keyed by a client-generated id so retries stay idempotent, a status machine from sending to read, dedupe and server-sequence ordering so the list is never wrong, a virtualized message list that sticks to the bottom only when the user is already there and preserves position when you prepend history, and an IndexedDB-backed outbox that hydrates instantly offline and drains on reconnect. Lead with the optimistic-send-plus-outbox story and the scroll behavior; those are what separate a chat demo from something you'd ship.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…