Back to Scenarios & Design Questions
Q9Question 9 of 67

How would you design a real-time chat / messaging system like WhatsApp or Slack

Key Concepts
  • Persist first, deliver second: the database is the source of truth, and delivery is a projection of what is already stored.
  • WebSockets provide full-duplex persistent connections for instant bidirectional message delivery.
  • Per-conversation ordering uses a monotonic sequence number assigned at persist time, not global ordering.
  • Delivery and read are two distinct state transitions tracked per recipient per message.
  • Offline users receive a push notification; on reconnect the client replays from its last-seen sequence.
  • Scaling stateful WebSocket servers uses sticky sessions plus Redis pub-sub fan-out across servers.
  • Deduplicate with a client-generated message ID so retries never create duplicate messages.

Definition

Persist first then deliver, use WebSockets for live full-duplex delivery with a Redis pub-sub fan-out across servers, order per conversation with a sequence number, and fall back to queue plus push for offline users.
  • Persist first – Write the message to durable storage and assign its sequence before acknowledging the sender or delivering to anyone.
  • WebSockets – A persistent full-duplex channel where server and client send frames any time without re-establishing.
  • Sequence number – A monotonic counter per conversation assigns at persist time; clients render by sequence, not arrival time.
  • Receipts – Delivery (reached device) and read (user opened) are separate states per recipient.
  • Offline – Message already persisted; enqueue delivery + push notification; replay on reconnect.
  • Scaling – Sticky sessions pin a user to a server; Redis pub-sub lets a message arriving on server B reach a socket on server A.

At National Bank of India's secure messaging, when Rajesh sends a message to his relationship manager Priya, the bank first writes it in the permanent ledger and only then says "sent". If Priya is online, a dedicated open phone line (WebSocket) delivers it instantly. If she is away, the message waits in her inbox and her phone buzzes. The bank stamps each message with a running number per conversation so they always appear in the right order.

Understanding the Concepts

Why must you persist before delivering?

If you deliver first and persist later, a crash between the two loses the message even though the sender saw "sent". Persist-first makes the database the source of truth. Delivery is just a projection of what is already stored.

JAVA
package com.bank.chat.service;

import java.time.Instant;

public class MessageService {

    private final MessageRepository repository;
    private final SequenceGenerator sequenceGenerator;
    private final DeliveryService deliveryService;

    public MessageService(MessageRepository repository,
                           SequenceGenerator sequenceGenerator,
                           DeliveryService deliveryService) {
        this.repository = repository;
        this.sequenceGenerator = sequenceGenerator;
        this.deliveryService = deliveryService;
    }

    public Message send(String conversationId, String senderId, String text) {
        // Step 1: Assign sequence and persist FIRST
        long seq = sequenceGenerator.next(conversationId);
        Message msg = new Message(conversationId, senderId, text, seq, Instant.now());
        repository.save(msg);

        // Step 2: Deliver (best effort; message is safe in DB)
        deliveryService.pushToRecipients(msg);

        return msg; // Sender sees "sent" = durably stored
    }
}

How do WebSockets compare to long-polling?

| Aspect | WebSocket | Long-Polling |
|---|---|---|
| Direction | Full-duplex (both ways anytime) | Simulated push (hold request, respond, re-request) |
| Latency | Very low (frame-level) | Higher (reconnection overhead) |
| Server resources | One persistent connection per client | Repeated HTTP connections |
| Use case | Chat, typing, presence | Fallback when WebSocket is blocked |

JAVA
package com.bank.chat.websocket;

import javax.websocket.OnMessage;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

@ServerEndpoint("/chat/{conversationId}")
public class ChatEndpoint {

    @OnMessage
    public void onMessage(String messageJson, Session session) {
        // Receive from client, persist, then broadcast to recipients
    }

    public void pushToClient(Session session, String messageJson) {
        session.getAsyncRemote().sendText(messageJson);
    }
}

How does per-conversation ordering work?

Assign each message a monotonic sequence number scoped to its conversation at persist time. Clients render by sequence, not arrival time.

JAVA
package com.bank.chat.service;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

public class SequenceGenerator {

    private final ConcurrentHashMap<String, AtomicLong> sequences =
        new ConcurrentHashMap<>();

    public long next(String conversationId) {
        return sequences
            .computeIfAbsent(conversationId, k -> new AtomicLong(0))
            .incrementAndGet();
    }
}
TEXT
# Conversation "conv-123":
# Message 1: seq=1 (Rajesh: "Hi Priya")
# Message 2: seq=2 (Priya: "Hello Rajesh")
# Even if seq=2 arrives at the client before seq=1,
# the client renders by sequence: 1, 2.

How do delivery and read receipts work?

They are two distinct states per recipient. The recipient's client sends acknowledgment events back over the WebSocket.

JAVA
package com.bank.chat.model;

public class MessageReceipt {
    private String messageId;
    private String recipientId;
    private ReceiptState state; // SENT, DELIVERED, READ
    private long timestamp;

    public enum ReceiptState { SENT, DELIVERED, READ }
}

// Flow:
// 1. Message persisted -> state = SENT (one tick)
// 2. Recipient's device receives -> sends DELIVERED ack -> state = DELIVERED (two ticks)
// 3. Recipient opens conversation -> sends READ ack -> state = READ (blue ticks)

How do you handle offline users?

The message is already persisted. Enqueue a delivery task and send a push notification. On reconnect, the client reports its last-seen sequence and the server replays everything newer.

JAVA
package com.bank.chat.service;

import java.util.List;

public class OfflineDeliveryService {

    private final MessageRepository repository;
    private final PushNotificationService pushService;

    public OfflineDeliveryService(MessageRepository repository,
                                   PushNotificationService pushService) {
        this.repository = repository;
        this.pushService = pushService;
    }

    public void handleOfflineUser(String userId, Message message) {
        // Send push notification
        pushService.sendPush(userId, "New message from " + message.getSenderId());
    }

    public List<Message> replayOnReconnect(String conversationId, long lastSeenSeq) {
        return repository.findAfterSequence(conversationId, lastSeenSeq);
    }
}

How do you scale stateful WebSocket servers?

WebSockets are stateful: a user's socket lives on one server. Redis pub-sub lets a message arriving on server B reach a socket on server A.

JAVA
package com.bank.chat.pubsub;

public class RedisPubSubBridge {

    // Server B receives a message from Rajesh for Priya.
    // Priya's socket is on Server A.
    // Server B publishes to Redis channel "user:PRIYA"
    public void publish(String userId, String messageJson) {
        redisTemplate.convertAndSend("user:" + userId, messageJson);
    }

    // Server A subscribes to "user:PRIYA" and delivers to local socket
    public void onRedisMessage(String channel, String messageJson) {
        Session priyaSession = localSessions.get(extractUserId(channel));
        if (priyaSession != null) {
            priyaSession.getAsyncRemote().sendText(messageJson);
        }
    }
}

How do you handle message deduplication?

The client generates a unique message ID. The server deduplicates on insert so retries never create duplicate messages.

JAVA
package com.bank.chat.service;

public class DeduplicationService {

    private final MessageRepository repository;

    public boolean isDuplicate(String clientMessageId) {
        return repository.existsByClientMessageId(clientMessageId);
    }

    // Client sends: {clientMessageId: "uuid-abc-123", text: "Hi"}
    // First time: save message, return OK
    // Retry (same clientMessageId): skip, return existing message
}

What are common interview follow-up questions?

How do you handle group chats with thousands of members? How do you implement message search? How would you add end-to-end encryption? How do you handle media messages (images, videos)? What happens when the Redis pub-sub layer goes down?

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│              REAL-TIME CHAT ARCHITECTURE                  │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  Rajesh (Server A)              Priya (Server B)         │
│     │                              │                     │
│  WebSocket                      WebSocket                │
│     │                              │                     │
│     ▼                              │                     │
│  ┌──────────────┐                  │                     │
│  │ 1. Persist   │  (DB is source   │                     │
│  │    + assign  │   of truth)      │                     │
│  │    sequence  │                  │                     │
│  └──────┬───────┘                  │                     │
│         │                          │                     │
│  ┌──────▼───────┐                  │                     │
│  │ 2. Publish   │                  │                     │
│  │    to Redis  │──────────────────│                     │
│  │    pub-sub   │  channel:        │                     │
│  └──────────────┘  "user:PRIYA"    │                     │
│                         │          │                     │
│                         ▼          │                     │
│                  ┌─────────────┐   │                     │
│                  │ Server B    │   │                     │
│                  │ subscribes  │───▶ Push to Priya's     │
│                  │ delivers    │     WebSocket           │
│                  └─────────────┘                         │
│                                                          │
│  If Priya offline:                                       │
│  Push notification + replay on reconnect                 │
└──────────────────────────────────────────────────────────┘

Key Takeaway

A real-time chat system is built around one rule above all: persist the message to durable storage and assign its sequence before acknowledging the sender or delivering to anyone. At National Bank of India, a bug where messages were delivered before persisting caused Rajesh's messages to Priya to be lost on a crash. Persist-first fixed that permanently because the database is the source of truth and delivery is just a projection.

For live transport, Pankaj uses WebSockets for full-duplex instant delivery, with long-polling as a fallback. Ordering is guaranteed per conversation with a monotonic sequence number assigned at persist time, so even if the network reorders delivery, the display is always correct. Delivery and read are separate state transitions tracked per recipient, and offline users get a push notification with replay on reconnect.

The hardest scaling problem is that WebSockets are stateful: Rajesh's socket is on server A while Priya's message arrives on server B. Redis pub-sub bridges this by publishing to a per-user channel that the server holding the recipient's socket subscribes to. Sticky sessions keep reconnections landing on the same server. Deduplication with a client-generated message ID ensures retries never create duplicates.

Remember one sentence: Persist first so the database is the source of truth, deliver over full-duplex WebSockets with Redis pub-sub fan-out across servers, order per conversation with a sequence number, and fall back to push plus replay for offline users.

---

Share
Use ← → arrow keys to navigate