20. Chat Application (WhatsApp-style) Advanced

A. Realistic Interview Prompt

"Design a chat application like WhatsApp. Users can message each other one-on-one or in groups, see delivery and read receipts, and reconnect after being offline without losing or duplicating messages."

B. Difficulty

Advanced. This problem is the natural capstone of the Observer arc this document has built since Ride-Sharing's first small hook (Problem 5): a chat conversation is, structurally, a Pub-Sub topic with its participants as subscribers. The genuine new difficulty is per-conversation message ordering, exactly-once delivery across reconnects, and correctly separating the Mediator concern (decoupling participants from each other) from the Observer concern (notifying connected clients).

C. What This Problem Tests

D. Requirements

Functional

  1. Send a 1:1 or group message; all participants receive it in a consistent order
  2. Track delivery and read status per message (and, for groups, per recipient)
  3. Show a user's online/offline presence, correctly across multiple simultaneously-connected devices
  4. On reconnect after being offline, deliver missed messages exactly once, in order

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Is read/delivery status tracked per-recipient in a group, or just an aggregate for the whole conversation?Per-recipient tracking means Message needs a receipt map, not a single status fieldShape of the delivery-status model
2Can one user be connected from multiple devices simultaneously?Introduces a presence race (is the user "online" if any device is connected, or the most-recently-active one?) and multi-device message fan-outDesign of the presence-tracking mechanism
3How is message order determined when two participants send at nearly the same instant?Client timestamps are subject to clock skew; determines whether ordering needs a server-assigned sequenceDesign of per-conversation sequencing
4What happens to messages sent while a recipient is offline?Determines whether a per-user/per-conversation message queue with reconnect-replay is neededDesign of the offline-delivery queue

F. Assumptions

G. Actors

ActorResponsibility
UserSends/receives messages from one or more devices
Conversation (Direct or Group)Owns the participant list, message order, and delivery fan-out
ChatService (facade)Top-level API coordinating send, receipt tracking, and reconnect replay

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
UserOne account, possibly connected from multiple devicesid, connectedDevices—Conversation membership logic directly
Conversation (abstract)Mediator between its participants, and the Observer subject they're all subscribed toparticipants, messages, nextSequencesend(message), addParticipant()Transport-level delivery to a specific device
DirectConversationExactly-two-participant conversation———
GroupConversationN-participant conversation; the Mediator role is load-bearing here—removeParticipant()—
MessageOne message and its per-recipient receiptssender, content, sequenceNo, receipts——
MessageStatus (interface)One delivery-status state's legal transitions, per recipient—onDelivered(), onRead()—
PresenceTrackerA user's aggregate online/offline status across devicesuserId, connectedDeviceIdsdeviceConnected(), deviceDisconnected(), isOnline()—
ChatService (facade)Top-level orchestration—sendMessage(), reconnect(userId, lastSeenSequence)—

I. Class Relationships

J. Class Diagram

*

*

User

id

Conversation

participants

nextSequence

+send(message)

+addParticipant()

DirectConversation

GroupConversation

+removeParticipant()

Message

sender

content

sequenceNo

receipts

«interface»

MessageStatus

+onDelivered()

+onRead()

PresenceTracker

+deviceConnected()

+deviceDisconnected()

+isOnline()

ChatService

+sendMessage()

+reconnect()

View Mermaid source
classDiagram
  class User {
    id
  }
  class Conversation {
    participants
    nextSequence
    +send(message)
    +addParticipant()
  }
  class DirectConversation
  class GroupConversation {
    +removeParticipant()
  }
  class Message {
    sender
    content
    sequenceNo
    receipts
  }
  class MessageStatus {
    <<interface>>
    +onDelivered()
    +onRead()
  }
  class PresenceTracker {
    +deviceConnected()
    +deviceDisconnected()
    +isOnline()
  }
  class ChatService {
    +sendMessage()
    +reconnect()
  }
  DirectConversation --|> Conversation
  GroupConversation --|> Conversation
  Conversation --> "*" User
  Conversation --> "*" Message
  Message --> MessageStatus
  ChatService --> Conversation
  ChatService --> PresenceTracker

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
ConversationParticipant decoupling (Mediator) and message fan-out (Observer subject), sequencingTransport/socket delivery to a specific device
MessageStatus implementationsOne recipient's delivery-status transitionsFan-out itself
PresenceTrackerAggregating multi-device connection state into one online/offline factMessage delivery
ChatServiceTop-level orchestration, reconnect replaySequencing logic, status-transition legality

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPConversation owns membership/sequencing/fan-out; MessageStatus owns receipt transitions; PresenceTracker owns multi-device aggregationOne User class directly tracking every other user it's ever messaged, plus delivery status, plus presence
OCPA new conversation type (e.g. a broadcast channel) is a new Conversation subtype; a new delivery-status nuance is a new MessageStatusA ChatService.send() method with an if isGroup branch scattered through every method that touches messaging
LSPDirectConversation and GroupConversation both honor the same send()/addParticipant() contract regardless of participant countDirectConversation.addParticipant() silently doing nothing or throwing in a way callers don't expect from the shared interface
ISPMessageStatus stays a small, focused interfaceA combined "MessagePolicy" interface mixing delivery status with retention/encryption rules
DIPChatService depends on the Conversation abstraction, not on DirectConversation/GroupConversation concretelyChatService.send() branching on conversation type internally instead of polymorphic dispatch

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
MediatorParticipants shouldn't need direct references to every other participant as group size growsGroupConversationWeak justification for strictly 1:1 chat — see I
ObserverA sent message needs to reach every connected subscriber without the send path knowing who's listeningConversation as Pub-Sub topic, reusing Problem 10's foundationNever skip — this is the structural core of the whole problem
StateA message's delivery status has genuinely different legal next-transitions per recipientMessageStatusSkip if the round only needs a single boolean "delivered"

Explicitly avoided: giving every User direct references to every other participant they've ever chatted with — see I for why this is the specific mesh-growth problem Mediator exists to prevent.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Conversation typesDirect, GroupBroadcast channels (one-to-many, no reply)New Conversation subtype, additive
Message contentTextMedia, reactions, replies-in-threadMessage payload becomes polymorphic, sequencing/receipts unaffected
Presence granularityBinary online/offlineAway/typing/last-seen-timestampPresenceTracker internals, same public isOnline()-style contract

O. Design Evolution

What stays stable: ChatService's public send/reconnect API. New conversation and content types are additive.

P. API / Interface Design

interface MessageStatus {
  MessageStatus onDelivered();
  MessageStatus onRead();
}

class ChatService {
  Message sendMessage(User sender, Conversation conversation, String content);
  List<Message> reconnect(User user, long lastSeenSequence);   // exactly-once replay
}

Ownership/validation notes: sendMessage() assigns the message's sequenceNo from the target Conversation's own monotonic counter — never from the client's clock — and reconnect() returns only messages strictly after lastSeenSequence, making replay naturally idempotent: calling it twice with the same cursor returns the same (or an empty) result, never a duplicate delivery.

Q. Pseudocode — Critical Logic

Conversation.send(sender, content):
  lock(this.sequenceCounter)
  try:
    seq = ++this.nextSequence      // server-assigned, monotonic per conversation
    message = new Message(sender, content, seq)
  finally:
    unlock(this.sequenceCounter)
  this.messages.append(message)
  for participant in this.participants:      // Observer fan-out
    if presenceTracker.isOnline(participant):
      deliver(participant, message)
    else:
      offlineQueueFor(participant).enqueue(message)
  return message

ChatService.reconnect(user, lastSeenSequence):
  missed = offlineQueueFor(user).allAfter(lastSeenSequence)   // idempotent: same cursor -> same result
  offlineQueueFor(user).clearUpTo(lastSeenSequence + missed.size)
  return missed

R. End-to-End Use Case

groupChat.send(userA, "Hi all") → sequenceNo=42 assigned under the conversation's counter
  │  fan-out: userB online → delivered immediately; userC offline → enqueued
  ▼
userC reconnects with lastSeenSequence=41
  │  reconnect() returns exactly [message #42], marked delivered
  │  a duplicate reconnect() call with the same cursor returns the same result, not a re-delivery
  ▼
userB opens the chat → MessageStatus for userB's receipt transitions DELIVERED → READ

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
Monotonic per-conversation counterServer-assigned message orderingImmune to client clock skew, unlike sorting by client-reported timestampA distributed sequence (e.g. per-partition offset) is the real-world analogue at scale — see Y
Per-user offline message queue, keyed by conversation and sequence numberExactly-once reconnect replayA cursor (last-seen sequence) makes replay idempotent by construction — replaying from the same cursor always yields the same resultA simple unread-count flag is cheaper but can't reconstruct exactly which messages were missed
Receipt map (recipientId → MessageStatus) per messagePer-recipient delivery/read tracking in groupsA single status field can't represent "read by 3 of 5 participants"—

T. Complexity Analysis

OperationTimeSpace
send() to a conversation with k participantsO(k) fan-outO(1) amortized per participant's queue
reconnect()O(missed messages)O(missed messages) until delivered
Receipt status updateO(1) per recipientO(participants) per message

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Add typing indicators and last-seen timestamps."Whether presence was already isolated behind one abstractionExtend PresenceTracker's internal state; public contract unchangedPresenceTracker onlyValidates the presence abstraction from N
"What if the same user is logged in on phone and laptop, and closes the laptop?"Whether presence assumed a single device per userPresenceTracker already models a device set — removing one device only flips to offline if it was the last oneNone if designed correctly; PresenceTracker if notTests the multi-device race directly — see W
"Scale group conversations to thousands of participants (e.g. a broadcast channel)."Whether fan-out was assumed to be synchronous, in-process deliveryFan-out moves to an async message queue per participant, same conceptual Observer relationship, different transportConversation's delivery mechanism, not its public APILLD→HLD boundary — see Y

W. Concurrency

Shared mutable state: a conversation's sequence counter, and a user's set of connected devices.

X. Persistence

Messages and their receipts are durable by nature — a chat history that disappears on restart is a trust-destroying failure, matching Movie Ticket Booking's and E-commerce's treatment of confirmed transactional records. Presence state is the opposite: inherently transient, tied to an active connection, and reasonably rebuilt from scratch (as "offline") on any restart rather than persisted.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDConversation as Mediator+Observer, per-conversation sequencing, MessageStatus, PresenceTracker, reconnect-cursor replay
HLDDistributed pub-sub transport for fan-out at scale (this document's Pub-Sub, Problem 10, generalized to production infrastructure), multi-region message replication, end-to-end encryption key exchange
Explicitly out of scopeDistributed-systems transport infrastructure and encryption protocol detail unless the round pivots to HLD

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BRecommendation
Message ordering authorityServer-assigned sequence per conversationClient-reported timestampServer-assigned — the only option that guarantees a single consistent order across every participant regardless of clock skew
Offline delivery fan-outPer-participant queue, replayed on reconnectPush-only, missed messages simply lostPer-participant queue — a chat app that silently drops messages sent while a user is offline fails the core promise of the product

AC. Interviewer Evaluation

Strong signal: naming Conversation as both a Mediator and an Observer subject and explaining why those are complementary, not competing, framings; assigning message order from a server-side counter unprompted; designing reconnect as cursor-based (hence idempotent) rather than flag-based; modeling presence as a device set rather than a single racy boolean.

Red flag: direct peer references between users in a group; client-timestamp-based ordering; a single delivered/not-delivered boolean per message; a presence boolean that races across devices.

AD. Interview Simulation

Interviewer: Design a chat application supporting both 1:1 and group messaging.
Candidate: I'd model a Conversation as the shared abstraction for both — every participant holds a reference to the conversation, never to each other directly. That's a Mediator role: it prevents an O(n²) mesh of direct references as a group grows. It's simultaneously an Observer subject — every participant is a subscriber, and sending a message is a publish. That's the same subject/observer shape this design has used as a small hook in several other systems, generalized fully here.
Interviewer: How do you keep message order consistent for everyone in a group?
Candidate: I wouldn't trust client timestamps — clock skew between devices could reorder messages differently for different viewers. I'd assign a sequence number from the conversation's own server-side counter at the moment the message is received, so there's one authoritative order everyone agrees on.
Interviewer: A user reconnects after being offline for an hour. How do you avoid duplicating or losing messages?
Candidate: Reconnect takes the client's last-seen sequence number as a cursor and replays everything strictly after it. That's naturally idempotent — replaying from the same cursor twice returns the same result, so a retried reconnect can't double-deliver.
Interviewer: What about a user logged in on two devices, and one disconnects?
Candidate: I'd model presence as a set of connected device ids per user rather than one boolean — "online" just means the set is non-empty. Add and remove on a set commute regardless of arrival order, which sidesteps the race a single last-writer-wins boolean would have.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
GeeksforGeeks — Design a Chat SystemRequirements-and-entity walkthrough covering 1:1/group messaging, delivery status, and presenceClear functional decomposition of the messaging surfaceGroup chat is generally not explicitly framed through Mediator, and message ordering is often left as an implementation detail rather than an explicit server-assigned-sequence decisionGood functional baseline; this document's explicit Mediator+Observer framing and sequencing/idempotency depth are the additions worth layering on top
AlgoMaster — Design a Chat ApplicationStructured walkthrough with class diagrams covering users, conversations, and messagesReasonably explicit about the conversation-as-shared-abstraction idea for 1:1 vs. groupExactly-once reconnect delivery and the multi-device presence race are not typically treated as first-class concurrency concernsGood structural starting point; this document's reconnect-cursor idempotency and device-set presence model are the concurrency depth worth adding

AF. Interview Cheat Sheet

ProblemChat Application (WhatsApp-style)
DifficultyAdvanced
Core requirements1:1 and group messaging, delivery/read receipts, presence, exactly-once reconnect delivery
Key clarificationsPer-recipient receipts in groups? Multi-device presence? Ordering guarantee needed? Offline delivery required?
Core entitiesUser, Conversation, DirectConversation, GroupConversation, Message, MessageStatus, PresenceTracker, ChatService
Key relationshipsConversation as Mediator (decouples participants) and Observer subject (fan-out); State for per-recipient receipts
Key patternsMediator, Observer, State
Most important decisionConversation as both Mediator and Observer subject — the capstone of this document's Observer arc
Likely-changing behaviorNew conversation types, richer message content, finer presence granularity
Concurrency concernServer-assigned per-conversation ordering; idempotent reconnect replay; multi-device presence race
Top follow-upsTyping/last-seen indicators, multi-device presence edge cases, broadcast-scale fan-out
Common mistakesDirect peer references instead of Mediator; client-timestamp ordering; single delivered flag instead of per-recipient receipts; racy presence boolean
What NOT to over-engineerDon't design distributed transport infrastructure or encryption protocols unless asked

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

// Problem 20: Chat Application (WhatsApp-style)
// Patterns: Mediator + Observer (Conversation), State (MessageStatus per recipient), Facade (ChatService)
import java.util.*;

public class p20 {

    // ---------- User ----------
    static class User {
        final String id;
        final Set<String> connectedDevices = new HashSet<>();
        User(String id) { this.id = id; }
    }

    // ---------- MessageStatus (per-recipient state machine) ----------
    interface MessageStatus {
        MessageStatus onDelivered();
        MessageStatus onRead();
        String name();
    }

    static class SentStatus implements MessageStatus {
        @Override public MessageStatus onDelivered() { return new DeliveredStatus(); }
        @Override public MessageStatus onRead() { throw new IllegalStateException("Must be delivered before read"); }
        @Override public String name() { return "SENT"; }
    }
    static class DeliveredStatus implements MessageStatus {
        @Override public MessageStatus onDelivered() { return this; }
        @Override public MessageStatus onRead() { return new ReadStatus(); }
        @Override public String name() { return "DELIVERED"; }
    }
    static class ReadStatus implements MessageStatus {
        @Override public MessageStatus onDelivered() { return this; }
        @Override public MessageStatus onRead() { return this; }
        @Override public String name() { return "READ"; }
    }

    // ---------- Message ----------
    static class Message {
        final User sender;
        final String content;
        final long sequenceNo;
        final Map<String, MessageStatus> receipts = new HashMap<>(); // recipientId -> status

        Message(User sender, String content, long sequenceNo, List<User> recipients) {
            this.sender = sender; this.content = content; this.sequenceNo = sequenceNo;
            for (User r : recipients) receipts.put(r.id, new SentStatus());
        }

        void markDelivered(String userId) { receipts.computeIfPresent(userId, (k, s) -> s.onDelivered()); }
        void markRead(String userId) { receipts.computeIfPresent(userId, (k, s) -> s.onRead()); }
    }

    // ---------- PresenceTracker ----------
    static class PresenceTracker {
        private final Map<String, Set<String>> connectedDeviceIds = new HashMap<>();

        void deviceConnected(String userId, String deviceId) {
            connectedDeviceIds.computeIfAbsent(userId, k -> new HashSet<>()).add(deviceId);
        }
        void deviceDisconnected(String userId, String deviceId) {
            Set<String> devices = connectedDeviceIds.get(userId);
            if (devices != null) devices.remove(deviceId);
        }
        boolean isOnline(String userId) {
            Set<String> devices = connectedDeviceIds.get(userId);
            return devices != null && !devices.isEmpty();
        }
    }

    // Simple per-user append-only log of messages missed while offline.
    // NOTE (interview-scale simplification): the cursor passed to reconnect() is treated as an
    // index into this per-user queue (not the message's own per-conversation sequenceNo), since
    // a user's offline queue spans multiple conversations whose sequence numbers aren't
    // comparable to each other. Same cursor -> same slice, so replay is idempotent.
    static class OfflineQueue {
        private final List<Message> messages = new ArrayList<>();
        void enqueue(Message m) { messages.add(m); }
        List<Message> allAfter(long cursor) {
            int from = Math.min((int) cursor, messages.size());
            return new ArrayList<>(messages.subList(from, messages.size()));
        }
        void clearUpTo(long cursor) {
            int upTo = Math.min((int) cursor, messages.size());
            messages.subList(0, upTo).clear();
        }
    }

    // ---------- Conversation (Mediator + Observer subject) ----------
    static abstract class Conversation {
        final String id;
        final List<User> participants = new ArrayList<>();
        final List<Message> messages = new ArrayList<>();
        long nextSequence = 0;
        private final PresenceTracker presenceTracker;
        private final Map<String, OfflineQueue> offlineQueues; // shared reference, owned by ChatService

        Conversation(String id, PresenceTracker presenceTracker, Map<String, OfflineQueue> offlineQueues) {
            this.id = id; this.presenceTracker = presenceTracker; this.offlineQueues = offlineQueues;
        }

        void addParticipant(User user) {
            if (!participants.contains(user)) participants.add(user);
        }

        Message send(User sender, String content) {
            long seq = ++nextSequence; // server-assigned, monotonic per conversation
            List<User> recipients = new ArrayList<>();
            for (User p : participants) if (!p.id.equals(sender.id)) recipients.add(p);
            Message message = new Message(sender, content, seq, recipients);
            messages.add(message);

            for (User participant : recipients) { // Observer fan-out
                if (presenceTracker.isOnline(participant.id)) {
                    message.markDelivered(participant.id);
                } else {
                    offlineQueues.computeIfAbsent(participant.id, k -> new OfflineQueue()).enqueue(message);
                }
            }
            return message;
        }
    }

    static class DirectConversation extends Conversation {
        DirectConversation(String id, User a, User b, PresenceTracker tracker, Map<String, OfflineQueue> offlineQueues) {
            super(id, tracker, offlineQueues);
            participants.add(a); participants.add(b);
        }
        @Override void addParticipant(User user) {
            throw new UnsupportedOperationException("DirectConversation is fixed at 2 participants");
        }
    }

    static class GroupConversation extends Conversation {
        GroupConversation(String id, List<User> initialParticipants, PresenceTracker tracker, Map<String, OfflineQueue> offlineQueues) {
            super(id, tracker, offlineQueues);
            participants.addAll(initialParticipants);
        }
        void removeParticipant(User user) { participants.remove(user); }
    }

    // ---------- ChatService (Facade) ----------
    static class ChatService {
        private final PresenceTracker presenceTracker = new PresenceTracker();
        private final Map<String, OfflineQueue> offlineQueues = new HashMap<>();
        private int nextConversationId = 1;

        PresenceTracker getPresenceTracker() { return presenceTracker; }

        DirectConversation createDirectConversation(User a, User b) {
            return new DirectConversation("C" + (nextConversationId++), a, b, presenceTracker, offlineQueues);
        }

        GroupConversation createGroupConversation(List<User> participants) {
            return new GroupConversation("C" + (nextConversationId++), participants, presenceTracker, offlineQueues);
        }

        Message sendMessage(User sender, Conversation conversation, String content) {
            return conversation.send(sender, content);
        }

        // Exactly-once replay: client passes back the cursor from its last successful reconnect.
        List<Message> reconnect(User user, long lastSeenSequence) {
            OfflineQueue queue = offlineQueues.computeIfAbsent(user.id, k -> new OfflineQueue());
            List<Message> missed = queue.allAfter(lastSeenSequence);
            queue.clearUpTo(lastSeenSequence + missed.size());
            for (Message m : missed) m.markDelivered(user.id);
            return missed;
        }
    }

    // ---------- Demo ----------
    public static void main(String[] args) {
        ChatService chatService = new ChatService();
        User alice = new User("alice");
        User bob = new User("bob");
        User carol = new User("carol");

        chatService.getPresenceTracker().deviceConnected("alice", "alice-phone");
        chatService.getPresenceTracker().deviceConnected("bob", "bob-laptop");
        // carol is offline (no connected device)

        GroupConversation group = chatService.createGroupConversation(Arrays.asList(alice, bob, carol));

        Message m1 = chatService.sendMessage(alice, group, "Hello everyone!");
        System.out.println("Sent seq=" + m1.sequenceNo + " bob receipt=" + m1.receipts.get("bob").name()
                + " carol receipt=" + m1.receipts.get("carol").name());

        Message m2 = chatService.sendMessage(bob, group, "Hi Alice!");
        System.out.println("Sent seq=" + m2.sequenceNo);

        // Carol reconnects and should receive both missed messages, exactly once, in order.
        List<Message> missed = chatService.reconnect(carol, 0);
        System.out.println("Carol missed " + missed.size() + " messages, first content='" + missed.get(0).content + "'");

        // Idempotent retry with an already-advanced cursor should now return nothing new.
        List<Message> replay = chatService.reconnect(carol, missed.size());
        System.out.println("Carol replay after ack: " + replay.size() + " messages (expect 0)");

        m1.markRead("bob");
        System.out.println("Bob's receipt for m1 after reading: " + m1.receipts.get("bob").name());
    }
}