SystemCraftSystemCraft
Back to Designs

WhatsApp / Messaging System

Design a real-time messaging system supporting 1-on-1 and group chats, message delivery status, and online presence.

~9 min readmedium difficultymessaging
Practice

Low Level Design — covers service internals: DB schemas, API contracts, sequence flows, and class interfaces.

These designs are for learning purposes and may not be 100% accurate or production-ready. Always cross-reference with official docs and other resources. Report an issue if you spot something wrong.

LLD Overview

Low Level Design — WhatsApp Messaging

LLD focus: message persistence with delivery receipts, WebSocket fanout for real-time delivery, presence tracking, and offline push notifications.

Services

  1. ChatService — send/receive messages, track delivery status (sent → delivered → read)
  2. ThreadService — manage 1:1 and group threads, E2E encryption key exchange
  3. PresenceService — heartbeat-based last-seen timestamps in Redis

Data Model

Database Schemas

Cassandra — messages

cql
CREATE TABLE messages (
  thread_id   UUID,
  message_id  TIMEUUID,           -- encodes sent_at
  sender_id   UUID,
  content     BLOB,               -- encrypted payload
  status      VARCHAR,            -- sent/delivered/read
  PRIMARY KEY (thread_id, message_id)
) WITH CLUSTERING ORDER BY (message_id ASC);
Expand

PostgreSQL — threads + members

sql
CREATE TABLE threads (
  thread_id    UUID PRIMARY KEY,
  is_group     BOOLEAN DEFAULT false,
  name         VARCHAR(100)
);
CREATE TABLE thread_members (
  thread_id  UUID NOT NULL,
  user_id    UUID NOT NULL,
  joined_at  TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (thread_id, user_id)
);
Expand

Redis — Presence

Diagram
SET last_seen:{userId} {epoch_ms}
-- heartbeat call sets this, client polls on chat open
Expand

API Design

Service Interface Contracts

MessageService

typescript
interface MessageService {
  // WebSocket frame: { event: 'send_message', data: SendMessageDto }
  sendMessage(senderId: string, dto: SendMessageDto): Promise<MessageAck>;
  // WebSocket frame: { event: 'send_media', data: SendMediaDto }
  sendMedia(senderId: string, dto: SendMediaDto): Promise<MessageAck>;
  // WebSocket frame: { event: 'ack', data: { messageId, status } }
  acknowledgeDelivery(recipientId: string, messageId: string, status: 'delivered' | 'read'): Promise<void>;
}
Expand

SessionService

typescript
interface SessionService {
  // Called on WebSocket connect; registers server affinity in Redis
  openSession(userId: string, serverId: string, deviceId: string): Promise<void>;
  // Called on WebSocket disconnect; drains pending messages before closing
  closeSession(userId: string, deviceId: string): Promise<void>;
}
Expand

PresenceService

typescript
interface PresenceService {
  // REST: PUT /api/v1/presence { status: 'online' | 'offline' }
  setOnline(userId: string): Promise<void>;
  setOffline(userId: string): Promise<void>;
  // GET /api/v1/users/:userId/presence
  getLastSeen(userId: string): Promise<PresenceInfo>;
}
Expand

Sequence Diagram

Message Send + Delivery Receipt

Diagram
Expand

LLD Tradeoffs

Design Decisions

DecisionOptionsChosen
Message DBCassandra vs MySQLCassandra — append-only chat workload, efficient range scans by thread
Delivery trackingClient-side vs server-sideServer-side status column — single source of truth, auditble
Real-timeWebSocket vs SSEWebSocket — full-duplex, lower overhead for bidirectional chat
Offline deliveryStore-and-forward vs pushBoth — messages persisted in Cassandra; push notification wakes app
Expand

Core Classes

Core Class Interfaces

typescript
class MessageRepository {
  // Cassandra partition key: conversation_id, clustering key: message_id (TIMEUUID DESC)
  async insertMessage(msg: CreateMessageDto): Promise<Message>;
  async getMessagesByThread(conversationId: string, limit: number, beforeId?: string): Promise<Message[]>;
  async updateStatus(messageId: string, status: DeliveryStatus): Promise<void>;
  async getUndeliveredMessages(userId: string): Promise<Message[]>; // for offline catch-up
}

class DeliveryTracker {
  // Status flow: sent → delivered → read
  private static readonly TRANSITIONS: Record<DeliveryStatus, DeliveryStatus[]> = {
    sent:      ['delivered', 'failed'],
    delivered: ['read'],
    read:      [],
    failed:    ['sent'], // retry
  };

  async acknowledge(messageId: string, event: 'delivered' | 'read', recipientId: string): Promise<void> {
    await this.messageRepo.updateStatus(messageId, event);
    await this.notifySender(messageId, event); // WS push tick/double-tick to sender
  }
}

class EncryptionService {
  // Abstracts Signal Protocol double-ratchet key exchange
  async initiateSession(senderId: string, recipientId: string): Promise<SessionKeys>;
  async encrypt(plaintext: string, sessionKeys: SessionKeys): Promise<EncryptedPayload>;
  async decrypt(payload: EncryptedPayload, sessionKeys: SessionKeys): Promise<string>;
  async rotateKeys(sessionId: string): Promise<SessionKeys>; // forward secrecy ratchet step
}
Expand