1. Introduction — The Chat System Landscape
Real-time chat systems are among the most complex distributed systems in production today. WhatsApp handles 100 billion messages per day across 2 billion users. Slack processes 1.5 billion messages daily. Discord handles 4 billion messages per day across 150 million monthly active users. These systems must deliver messages with sub-second latency, handle millions of concurrent WebSocket connections, support group conversations of up to 200K members, and maintain message history across years of conversation.
Building a chat system requires mastering several distributed systems concepts: persistent WebSocket connections for real-time delivery, message queues for offline users, consistent hashing for connection routing, conflict resolution for concurrent edits, and end-to-end encryption for privacy. Unlike stateless HTTP APIs, chat systems are inherently stateful — the server must know which users are connected, which servers they are connected to, and how to route messages between them.
Interview Context: The real-time chat system design question tests your understanding of WebSocket architecture, message ordering, presence systems, and scaling stateful connections. It is one of the most frequently asked system design questions at companies like Meta, Google, Amazon, and Microsoft.
2. Functional & Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
| F1 | 1-on-1 messaging | Must | Send and receive messages between two users in real-time |
| F2 | Group messaging | Must | Create groups, add/remove members, send to all |
| F3 | Message history | Must | Persistent storage with searchable history |
| F4 | Online presence | Must | Show online/offline/last seen status |
| F5 | Read receipts | Should | Delivered and read indicators |
| F6 | Typing indicators | Should | "User is typing..." display |
| F7 | Media sharing | Should | Images, videos, files, voice messages |
| F8 | Push notifications | Must | Notify offline users of new messages |
| F9 | Message search | Nice | Full-text search across message history |
| F10 | End-to-end encryption | Nice | Signal protocol for private conversations |
Non-Functional Requirements
| Requirement | Target | Rationale |
| Message latency | < 200ms for online users | Real-time feel, competitive with WhatsApp |
| Throughput | 1 million messages/second | Scale for 500M daily active users |
| Concurrent connections | 10 million per region | Peak concurrent users during events |
| Message ordering | Strict per-conversation | Messages must appear in correct order |
| Delivery guarantee | At-least-once | Messages cannot be lost |
| Message retention | Indefinite (with limits) | WhatsApp: unlimited, Slack: free tier 90 days |
| Availability | 99.99% | Chat is communication infrastructure |
3. Capacity Estimation & Back-of-Envelope
Daily Volume Estimates
| Metric | Calculation | Result |
| Daily active users | Given | 500 million |
| Messages per user per day | 40 | 20 billion messages/day |
| Average message size | 100 bytes (text) + 200 bytes (metadata) | 300 bytes |
| Daily message data | 20B × 300 bytes | 6 TB/day |
| Peak QPS (10x average) | 20B / 86400 × 10 | ~2.3M messages/second |
| Average QPS | 20B / 86400 | ~231K messages/second |
| Storage (1 year) | 6 TB × 365 | ~2.2 PB |
| Media storage (1 year) | 500M users × 10 media/day × 500KB | ~900 TB/year |
Connection Estimates
| Metric | Value | Notes |
| Peak concurrent connections | 100 million | ~20% of DAU online simultaneously |
| Connections per server | 100K | With 2 CPU cores, 8GB RAM |
| WebSocket servers needed | 1,000 | 100M / 100K connections each |
| Connection state per user | 500 bytes | User ID, server ID, device info |
| Total connection state | 50 GB | Fits in Redis cluster |
4. Data Model & Storage Schema
Entity Relationship
erDiagram
USER {
bigint id PK
varchar username
varchar display_name
varchar avatar_url
varchar status
datetime last_seen_at
datetime created_at
}
CONVERSATION {
bigint id PK
varchar type
varchar name
varchar avatar_url
bigint creator_id FK
datetime created_at
datetime updated_at
}
CONVERSATION_MEMBER {
bigint conversation_id FK
bigint user_id FK
varchar role
datetime joined_at
datetime last_read_at
varchar notification_setting
}
MESSAGE {
bigint id PK
bigint conversation_id FK
bigint sender_id FK
varchar content
varchar message_type
varchar status
bigint reply_to_id FK
datetime created_at
datetime updated_at
datetime deleted_at
}
MESSAGE_REACTION {
bigint message_id FK
bigint user_id FK
varchar emoji
datetime created_at
}
CONVERSATION ||--o{ CONVERSATION_MEMBER : "has members"
CONVERSATION ||--o{ MESSAGE : "contains"
USER ||--o{ CONVERSATION_MEMBER : "belongs to"
USER ||--o{ MESSAGE : "sends"
MESSAGE ||--o{ MESSAGE_REACTION : "has reactions"
Cassandra Schema (Messages — Write-Optimized)
CQL
CREATE TABLE messages_by_conversation (
conversation_id timeuuid,
message_id timeuuid,
sender_id bigint,
content text,
message_type text, -- 'text', 'image', 'video', 'file', 'system'
reply_to_id timeuuid,
status text, -- 'sent', 'delivered', 'read'
created_at timestamp,
PRIMARY KEY (conversation_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC)
AND default_time_to_live = 315360000 -- 10 years
AND gc_grace_seconds = 864000; -- 10 days
CREATE TABLE messages_by_user (
user_id bigint,
last_active_conversation_id timeuuid,
message_id timeuuid,
conversation_id timeuuid,
content text,
created_at timestamp,
PRIMARY KEY (user_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);
-- For search: denormalized table indexed by content
CREATE TABLE messages_by_conversation_text (
conversation_id timeuuid,
message_id timeuuid,
sender_id bigint,
content text,
created_at timestamp,
PRIMARY KEY (conversation_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);
PostgreSQL Schema (Users & Conversations — Read-Optimized)
SQL
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
avatar_url TEXT,
status VARCHAR(20) DEFAULT 'offline',
last_seen_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE conversations (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(20) NOT NULL, -- 'direct', 'group', 'channel'
name VARCHAR(100),
avatar_url TEXT,
creator_id BIGINT REFERENCES users(id),
max_members INTEGER DEFAULT 200,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE conversation_members (
conversation_id BIGINT REFERENCES conversations(id),
user_id BIGINT REFERENCES users(id),
role VARCHAR(20) DEFAULT 'member', -- 'admin', 'member', 'readonly'
joined_at TIMESTAMP DEFAULT NOW(),
last_read_at TIMESTAMP,
notification_setting VARCHAR(20) DEFAULT 'all',
muted_until TIMESTAMP,
PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX idx_members_user ON conversation_members(user_id);
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
Why Cassandra for Messages?
| Requirement | Cassandra Strength | PostgreSQL Weakness |
| Write throughput | Millions of writes/sec | Limited by WAL and vacuum |
| Time-series data | Natural clustering by time | Partition pruning issues at scale |
| Horizontal scaling | Add nodes linearly | Vertical scaling with read replicas |
| Multi-region | Built-in replication | Complex logical replication |
| Query patterns | Optimized for partition reads | Flexible but slower at scale |
5. API Design
REST APIs
HTTP
// Create conversation
POST /api/v1/conversations
{
"type": "group",
"name": "Engineering Team",
"member_ids": [101, 102, 103, 104, 105]
}
// Get conversation list
GET /api/v1/conversations?page_size=20&cursor=abc123
// Get conversation details
GET /api/v1/conversations/{conversation_id}
// Add member to group
POST /api/v1/conversations/{conversation_id}/members
{
"user_id": 106,
"role": "member"
}
// Remove member
DELETE /api/v1/conversations/{conversation_id}/members/{user_id}
// Get message history (paginated)
GET /api/v1/conversations/{conversation_id}/messages?page_size=50&before=message_id_123
// Search messages
GET /api/v1/search/messages?q=hello&conversation_id=123&from_date=2025-01-01
// Upload media
POST /api/v1/media/upload
Content-Type: multipart/form-data
// Returns: { "media_id": "abc", "url": "https://cdn.example.com/abc.jpg" }
// Send message via REST (fallback for clients without WebSocket)
POST /api/v1/conversations/{conversation_id}/messages
{
"content": "Hello everyone!",
"message_type": "text",
"reply_to_id": "msg_123"
}
WebSocket Protocol
JSON
// Client → Server: Send message
{
"type": "message_send",
"id": "client_msg_abc123", // Client-generated ID for dedup
"conversation_id": "conv_123",
"content": "Hello world!",
"message_type": "text",
"timestamp": 1705123456789
}
// Server → Client: New message received
{
"type": "message_received",
"message_id": "msg_xyz789",
"conversation_id": "conv_123",
"sender_id": 101,
"sender_name": "Alice",
"content": "Hello world!",
"message_type": "text",
"created_at": "2025-01-15T10:30:00Z"
}
// Server → Client: Message delivered confirmation
{
"type": "message_delivered",
"client_id": "client_msg_abc123",
"message_id": "msg_xyz789",
"server_timestamp": 1705123456800
}
// Client → Server: Typing indicator
{
"type": "typing_start",
"conversation_id": "conv_123"
}
// Server → Client: User typing
{
"type": "user_typing",
"conversation_id": "conv_123",
"user_id": 101,
"user_name": "Alice"
}
// Client → Server: Mark as read
{
"type": "mark_read",
"conversation_id": "conv_123",
"last_message_id": "msg_xyz789"
}
// Server → Client: Presence update
{
"type": "presence_update",
"user_id": 102,
"status": "online",
"last_seen_at": "2025-01-15T10:30:00Z"
}
6. High-Level Architecture
flowchart TB
subgraph Clients
C1[Mobile App]
C2[Web App]
C3[Desktop App]
end
subgraph LoadBalancer["Load Balancer"]
LB[NGINX - L7 / WebSocket]
end
subgraph WebSocketCluster["WebSocket Servers"]
WS1[WS Server 1]
WS2[WS Server 2]
WS3[WS Server N]
end
subgraph Services
API[REST API Service]
CHAT[Chat Service]
PRESENCE[Presence Service]
SEARCH[Search Service]
MEDIA[Media Service]
NOTIF[Notification Service]
end
subgraph DataLayer
PG[(PostgreSQL)]
CASSANDRA[(Cassandra)]
REDIS[(Redis Cluster)]
ELASTIC[(Elasticsearch)]
S3[(S3 / Object Storage)]
end
subgraph MessageBus
KAFKA[Kafka]
end
C1 & C2 & C3 --> LB
LB --> WS1 & WS2 & WS3
WS1 & WS2 & WS3 --> KAFKA
KAFKA --> CHAT
CHAT --> CASSANDRA
API --> PG
PRESENCE --> REDIS
SEARCH --> ELASTIC
MEDIA --> S3
CHAT --> NOTIF
Component Responsibilities
| Component | Responsibility | Scaling |
| WebSocket Servers | Maintain persistent connections, route messages | Horizontal (stateless routing via consistent hashing) |
| Chat Service | Message processing, delivery, persistence | Horizontal (Kafka consumers) |
| Presence Service | Online/offline status, last seen tracking | Redis-backed, horizontally scalable |
| REST API | Conversation CRUD, member management, auth | Standard horizontal scaling |
| Media Service | Upload, download, thumbnail generation | CDN-backed, auto-scaling |
| Search Service | Full-text message search | Elasticsearch cluster scaling |
| Notification Service | Push notifications for offline users | Decoupled via Kafka |
'@
7. WebSocket Connection Management
sequenceDiagram
participant C as Client
participant LB as Load Balancer
participant WS as WebSocket Server
participant R as Redis
participant K as Kafka
C->>LB: WebSocket Upgrade Request
LB->>WS: Route to least-loaded server
WS->>WS: Authenticate JWT token
WS->>R: Store connection mapping (userId ? serverId)
WS->>K: Publish presence_online event
WS-->>C: Connection established
loop Heartbeat
C->>WS: Ping (every 30s)
WS->>WS: Reset timeout
WS-->>C: Pong
end
Note over C,WS: User goes offline
WS->>WS: Connection drops
WS->>R: Remove connection mapping
WS->>K: Publish presence_offline event
Connection Server Implementation
C#
public class WebSocketConnectionHandler
{
private readonly IConnectionMultiplexer _redis;
private readonly IProducer<string, PresenceEvent> _presenceProducer;
private readonly ConcurrentDictionary<string, WebSocket> _connections = new();
public async Task HandleConnectionAsync(HttpContext context)
{
var userId = await AuthenticateAsync(context);
if (userId == null)
{
context.Response.StatusCode = 401;
return;
}
var ws = await context.WebSockets.AcceptWebSocketAsync();
var connectionId = Guid.NewGuid().ToString();
_connections[connectionId] = ws;
// Store connection mapping in Redis
var db = _redis.GetDatabase();
var serverId = Environment.MachineName;
await db.HashSetAsync("user_connections", userId,
$"{serverId}:{connectionId}:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}");
// Publish online presence
await _presenceProducer.ProduceAsync("presence-events", new Message<string, PresenceEvent>
{
Key = userId,
Value = new PresenceEvent
{
UserId = userId,
Status = "online",
ServerId = serverId,
ConnectionId = connectionId,
Timestamp = DateTimeOffset.UtcNow
}
});
// Listen for incoming messages
var buffer = new byte[4096];
try
{
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
}
else if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
await HandleMessageAsync(userId, connectionId, message);
}
}
}
finally
{
_connections.TryRemove(connectionId, out _);
await db.HashDeleteAsync("user_connections", userId);
// Publish offline presence
await _presenceProducer.ProduceAsync("presence-events", new Message<string, PresenceEvent>
{
Key = userId,
Value = new PresenceEvent
{
UserId = userId,
Status = "offline",
ServerId = serverId,
ConnectionId = connectionId,
Timestamp = DateTimeOffset.UtcNow
}
});
}
}
}
Connection Routing Strategies
| Strategy | How It Works | Pros | Cons |
| Random | Connect to random server | Simple, evenly distributed | No locality, no affinity |
| Round-robin | Cycle through servers | Even distribution | Doesn't account for load |
| Consistent hashing | User hash maps to server | Minimal disruption on scale | May be uneven with few nodes |
| Least connections | Route to server with fewest connections | Balanced load | Requires load awareness at LB |
| Zone-aware | Route to same geographic zone | Lowest latency | Complex routing logic |
Heartbeat and Timeout
C#
public class ConnectionHealthMonitor
{
private readonly TimeSpan _heartbeatInterval = TimeSpan.FromSeconds(30);
private readonly TimeSpan _timeoutInterval = TimeSpan.FromSeconds(90);
public async Task MonitorConnectionsAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
foreach (var (connectionId, socket) in _connections)
{
if (socket.State != WebSocketState.Open)
{
_connections.TryRemove(connectionId, out _);
continue;
}
var lastActivity = await GetLastActivityAsync(connectionId);
if (DateTime.UtcNow - lastActivity > _timeoutInterval)
{
// Connection stale � close it
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Heartbeat timeout",
CancellationToken.None);
_connections.TryRemove(connectionId, out _);
continue;
}
// Send ping
var pingBytes = Encoding.UTF8.GetBytes("{\"type\":\"ping\"}");
await socket.SendAsync(
new ArraySegment<byte>(pingBytes),
WebSocketMessageType.Text,
true,
CancellationToken.None);
}
await Task.Delay(_heartbeatInterval, ct);
}
}
}
8. Message Delivery Pipeline
flowchart TB
A[Client sends message] --> B[WebSocket Server]
B --> C[Validate & Assign ID]
C --> D[Publish to Kafka]
D --> E[Chat Service Consumer]
E --> F[Store in Cassandra]
E --> G{Recipient online?}
G -->|Yes| H[Find recipient's WS server]
H --> I[Route message via internal gRPC]
I --> J[Recipient's WS server]
J --> K[Push to recipient's WebSocket]
G -->|No| L[Store in message queue]
L --> M[Push Notification Service]
M --> N[Send APNs/FCM notification]
Message Processing Flow
C#
public class ChatService
{
private readonly IProducer<string, ChatMessage> _kafkaProducer;
private readonly IMessageRepository _messageRepo;
private readonly IConnectionRouter _connectionRouter;
private readonly IPresenceService _presenceService;
public async Task<MessageResult> ProcessMessageAsync(ChatMessage message)
{
// 1. Assign server-side ID and timestamp
message.Id = GenerateTimeUUID();
message.ServerTimestamp = DateTime.UtcNow;
message.Status = "sent";
// 2. Publish to Kafka for async processing
await _kafkaProducer.ProduceAsync("chat-messages",
new Message<string, ChatMessage>
{
Key = message.ConversationId,
Value = message
});
// 3. Return acknowledgment to sender immediately
return new MessageResult
{
ClientId = message.ClientMessageId,
ServerMessageId = message.Id,
Status = "sent",
ServerTimestamp = message.ServerTimestamp
};
}
public async Task OnMessageConsumedAsync(ChatMessage message)
{
// 1. Persist to Cassandra
await _messageRepo.SaveAsync(message);
// 2. Get all conversation members
var members = await _conversationRepo.GetMembersAsync(message.ConversationId);
// 3. Deliver to online members
foreach (var memberId in members.Where(m => m != message.SenderId))
{
var isOnline = await _presenceService.IsOnlineAsync(memberId);
if (isOnline)
{
// Find the WebSocket server this user is connected to
var serverInfo = await _connectionRouter.FindServerAsync(memberId);
if (serverInfo != null)
{
// Route via gRPC to the correct WS server
await _connectionRouter.RouteToServerAsync(serverInfo, message);
}
}
else
{
// Queue for push notification
await QueuePushNotificationAsync(memberId, message);
}
}
}
}
Message Ordering
Critical Design Decision: Message Ordering
Messages within a conversation must be strictly ordered. Cassandra provides ordering by timeuuid (time-based UUID) at the partition level. Kafka provides ordering within a partition (using conversation_id as the key). This ensures that for a given conversation, messages are processed in the order they were sent.
| Ordering Requirement | Mechanism | Guarantee |
| Within a conversation | Cassandra clustering key + Kafka partitioning | Strict ordering |
| Across conversations | Not required | Independent ordering |
| Client-side rendering | Sequence numbers + client buffer | Display ordering |
| Conflict resolution | Last-writer-wins or operational transform | Eventual consistency |
9. Presence & Online Status
flowchart TB
A[WebSocket Server] -->|publish| B[Kafka: presence-events]
B --> C[Presence Service]
C --> D[Redis: user_status]
C --> E[Redis: user_last_seen]
D --> F[Query API]
E --> F
F --> G[Client: status display]
Redis Presence Implementation
C#
public class PresenceService
{
private readonly IConnectionMultiplexer _redis;
private readonly TimeSpan _onlineTtl = TimeSpan.FromMinutes(5);
private readonly TimeSpan _statusTtl = TimeSpan.FromDays(7);
public async Task SetOnlineAsync(string userId)
{
var db = _redis.GetDatabase();
// Set online status with TTL (auto-expires if heartbeat stops)
await db.StringSetAsync($"presence:{userId}:status", "online", _onlineTtl);
// Update last seen timestamp
await db.StringSetAsync($"presence:{userId}:last_seen",
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(),
_statusTtl);
// Add to online set for efficient bulk queries
await db.SetAddAsync("online_users", userId);
}
public async Task SetOfflineAsync(string userId)
{
var db = _redis.GetDatabase();
await db.StringSetAsync($"presence:{userId}:status", "offline", _statusTtl);
await db.StringSetAsync($"presence:{userId}:last_seen",
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(),
_statusTtl);
await db.SetRemoveAsync("online_users", userId);
}
public async Task<PresenceInfo> GetPresenceAsync(string userId)
{
var db = _redis.GetDatabase();
var status = await db.StringGetAsync($"presence:{userId}:status");
var lastSeen = await db.StringGetAsync($"presence:{userId}:last_seen");
return new PresenceInfo
{
Status = status.HasValue ? status.ToString() : "offline",
LastSeenAt = lastSeen.HasValue
? DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(lastSeen))
: null
};
}
public async Task<Dictionary<string, PresenceInfo>> GetBulkPresenceAsync(string[] userIds)
{
var db = _redis.GetDatabase();
var results = new Dictionary<string, PresenceInfo>();
// Pipeline all Redis calls
var tasks = userIds.Select(async userId =>
{
var info = await GetPresenceAsync(userId);
lock (results) { results[userId] = info; }
});
await Task.WhenAll(tasks);
return results;
}
// Heartbeat to keep online status fresh
public async Task HeartbeatAsync(string userId)
{
var db = _redis.GetDatabase();
await db.StringSetAsync($"presence:{userId}:status", "online", _onlineTtl);
}
}
Presence Strategies Comparison
| Strategy | Accuracy | Scalability | Cost | Used By |
| Redis TTL-based | ~5 seconds | High | Low | WhatsApp, Telegram |
| Database polling | ~30 seconds | Medium | Medium | Slack (for last seen) |
| Pub/sub broadcast | Instant | Low (fan-out) | High | Discord (online status) |
| Gossip protocol | ~10 seconds | Very high | Low | Cassandra-native systems |
10. Group Chat & Channels
Group Size Considerations
| Platform | Max Group Size | Max Channel Size | Delivery Strategy |
| WhatsApp | 1,024 | N/A | Push to all members |
| Telegram | 200K (supergroup) | Unlimited subscribers | Topic-based fan-out |
| Discord | N/A | Unlimited | Channel subscription model |
| Slack | N/A | Unlimited | Workspace + channel model |
| Facebook Messenger | 250 | N/A | Push to all members |
Large Group Delivery Strategy
C#
public class GroupMessageDelivery
{
public async Task DeliverToGroupAsync(ChatMessage message, Conversation conversation)
{
var memberCount = await _conversationRepo.GetMemberCountAsync(conversation.Id);
if (memberCount <= 100)
{
// Small group: fan-out to all members directly
await FanOutToMembersAsync(message, conversation);
}
else if (memberCount <= 10000)
{
// Medium group: fan-out with batching
await FanOutWithBatchingAsync(message, conversation);
}
else
{
// Large channel: topic-based delivery
await TopicBasedDeliveryAsync(message, conversation);
}
}
private async Task TopicBasedDeliveryAsync(ChatMessage message, Conversation conversation)
{
// For large channels, use a pub/sub topic model
// Members subscribe to topics, only receive messages for subscribed topics
var topic = $"conversation:{conversation.Id}";
// Publish to Kafka topic � only online subscribers consume
await _kafkaProducer.ProduceAsync("large-group-messages",
new Message<string, ChatMessage>
{
Key = conversation.Id.ToString(),
Value = message,
Headers = new Headers
{
{ "topic", Encoding.UTF8.GetBytes(topic) },
{ "conversation_type", Encoding.UTF8.GetBytes("channel") }
}
});
}
}
12. Message Search
Search Architecture
flowchart TB
A[Chat Service] -->|publish new messages| B[Kafka: message-events]
B --> C[Indexing Worker]
C --> D[Elasticsearch Index]
D --> E[Search API]
E --> F[Client Search UI]
Elasticsearch Index Mapping
JSON
{
"mappings": {
"properties": {
"message_id": { "type": "keyword" },
"conversation_id": { "type": "keyword" },
"sender_id": { "type": "long" },
"content": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": { "type": "keyword" },
"autocomplete": {
"type": "text",
"analyzer": "autocomplete_analyzer"
}
}
},
"message_type": { "type": "keyword" },
"created_at": { "type": "date" },
"members": { "type": "long" }
}
},
"settings": {
"analysis": {
"analyzer": {
"autocomplete_analyzer": {
"type": "custom",
"tokenizer": "autocomplete_tokenizer",
"filter": ["lowercase"]
}
},
"tokenizer": {
"autocomplete_tokenizer": {
"type": "edge_ngram",
"min_gram": 2,
"max_gram": 10,
"token_chars": ["letter", "digit"]
}
}
}
}
}
Search API
HTTP
// Search messages
GET /api/v1/search/messages
{
"query": "meeting tomorrow",
"conversation_id": "conv_123", // optional: scope to conversation
"sender_id": 101, // optional: filter by sender
"from_date": "2025-01-01",
"to_date": "2025-01-31",
"message_type": "text",
"page_size": 20,
"cursor": "search_cursor_abc"
}
// Response
{
"results": [
{
"message_id": "msg_xyz789",
"conversation_id": "conv_123",
"conversation_name": "Engineering Team",
"sender_name": "Alice",
"content": "Let's schedule a meeting tomorrow at 3pm",
"highlight": "...meeting tomorrow at 3pm...",
"created_at": "2025-01-15T10:30:00Z"
}
],
"total_hits": 42,
"cursor": "search_cursor_def"
}
13. Read Receipts & Typing Indicators
Read Receipt State Machine
stateDiagram-v2
[*] --> Sent: User sends message
Sent --> Delivered: Server confirms delivery
Delivered --> Read: Recipient opens chat
Read --> [*]
C#
public class ReadReceiptService
{
private readonly IConnectionMultiplexer _redis;
public async Task UpdateLastReadAsync(string userId, string conversationId, string messageId)
{
var db = _redis.GetDatabase();
// Update last read position for this user in this conversation
await db.HashSetAsync($"read_positions:{conversationId}", userId, messageId);
// Publish read event for real-time delivery to other members
await _kafkaProducer.ProduceAsync("read-receipts",
new Message<string, ReadReceiptEvent>
{
Key = conversationId,
Value = new ReadReceiptEvent
{
ConversationId = conversationId,
UserId = userId,
LastReadMessageId = messageId,
Timestamp = DateTime.UtcNow
}
});
}
public async Task<Dictionary<string, string>> GetReadPositionsAsync(string conversationId)
{
var db = _redis.GetDatabase();
var entries = await db.HashGetAllAsync($"read_positions:{conversationId}");
return entries.ToDictionary(
e => e.Name.ToString(),
e => e.Value.ToString());
}
// Calculate how many unread messages each member has
public async Task<Dictionary<string, int>> GetUnreadCountsAsync(
string conversationId, string latestMessageId)
{
var positions = await GetReadPositionsAsync(conversationId);
var result = new Dictionary<string, int>();
// Get all message IDs up to latest
var messages = await _messageRepo.GetMessageIdsAsync(conversationId, latestMessageId);
foreach (var (userId, lastReadId) in positions)
{
var unreadIndex = messages.FindIndex(m => m == lastReadId);
result[userId] = unreadIndex >= 0 ? messages.Count - unreadIndex - 1 : messages.Count;
}
return result;
}
}
Typing Indicator Implementation
C#
public class TypingIndicatorService
{
private readonly IConnectionMultiplexer _redis;
private readonly TimeSpan _typingTtl = TimeSpan.FromSeconds(5);
public async Task UserStartedTypingAsync(string userId, string conversationId)
{
var db = _redis.GetDatabase();
// Store typing status with TTL (auto-expires if user stops)
await db.StringSetAsync(
$"typing:{conversationId}:{userId}",
"1",
_typingTtl);
// Publish typing event
await _publisher.PublishAsync($"typing:{conversationId}",
JsonSerializer.Serialize(new TypingEvent
{
ConversationId = conversationId,
UserId = userId,
IsTyping = true
}));
}
public async Task UserStoppedTypingAsync(string userId, string conversationId)
{
var db = _redis.GetDatabase();
await db.KeyDeleteAsync($"typing:{conversationId}:{userId}");
await _publisher.PublishAsync($"typing:{conversationId}",
JsonSerializer.Serialize(new TypingEvent
{
ConversationId = conversationId,
UserId = userId,
IsTyping = false
}));
}
public async Task<List<string>> GetTypingUsersAsync(string conversationId)
{
var db = _redis.GetDatabase();
var pattern = $"typing:{conversationId}:*";
var keys = new List<string>();
var server = _redis.GetServer(_redis.GetEndPoints().First());
foreach (var key in server.Keys(pattern: pattern))
{
var userId = key.ToString().Split(':').Last();
keys.Add(userId);
}
return keys;
}
}
14. End-to-End Encryption
Signal Protocol Overview
| Component | Purpose | Implementation |
| Double Ratchet | Forward secrecy for each message | Each message uses a new ephemeral key |
| X3DH Key Agreement | Asynchronous key exchange | Pre-key bundles for offline users |
| AES-256-GCM | Symmetric encryption of message content | Authenticated encryption |
| HMAC-SHA256 | Message integrity verification | Detect tampering |
E2E Encryption Architecture
C#
public class EndToEndEncryptionService
{
// Generate and store pre-key bundles for offline key exchange
public async Task<PreKeyBundle> GeneratePreKeyBundleAsync(string userId)
{
var identityKeyPair = KeyHelper.GenerateIdentityKeyPair();
var signedPreKey = KeyHelper.GenerateSignedPreKey(identityKeyPair, 1);
var oneTimePreKeys = KeyHelper.GeneratePreKeys(100);
// Upload public keys to server
await _keyStore.StoreIdentityKeyAsync(userId, identityKeyPair.PublicKey);
await _keyStore.StoreSignedPreKeyAsync(userId, signedPreKey);
await _keyStore.StoreOneTimePreKeysAsync(userId, oneTimePreKeys.Select(k => k.PublicKey));
return new PreKeyBundle
{
UserId = userId,
IdentityKey = identityKeyPair.PublicKey,
SignedPreKey = signedPreKey.PublicKey,
SignedPreKeySignature = signedPreKey.Signature,
OneTimePreKey = oneTimePreKeys[0].PublicKey,
PreKeyId = oneTimePreKeys[0].Id
};
}
// Encrypt message before sending (client-side)
public EncryptedMessage EncryptMessage(
string plaintext, string senderIdentityKey, string recipientPreKeyBundle)
{
// Perform X3DH key agreement
var sharedSecret = SignalProtocol.PerformX3DH(
senderIdentityKey,
recipientPreKeyBundle.IdentityKey,
recipientPreKeyBundle.SignedPreKey,
recipientPreKeyBundle.OneTimePreKey);
// Derive message key from ratchet
var messageKey = _ratchet.GetMessageKey(sharedSecret);
// Encrypt with AES-256-GCM
var encrypted = AES256GCM.Encrypt(
Encoding.UTF8.GetBytes(plaintext),
messageKey,
out var nonce,
out var authTag);
return new EncryptedMessage
{
Ciphertext = encrypted,
Nonce = nonce,
AuthTag = authTag,
RatchetPublic = _ratchet.GetPublicRatchetKey(),
PreviousChainLength = _ratchet.PreviousChainLength,
MessageNumber = _ratchet.MessageNumber
};
}
}
E2E Encryption Trade-offs: When messages are E2E encrypted, the server cannot read message content. This means: (1) No server-side search of message content, (2) No content moderation or spam detection, (3) No message previews in notifications, (4) No message synchronization across devices without key sharing. Platforms like WhatsApp and Signal handle these trade-offs by accepting reduced functionality for privacy.
15. Offline Support & Message Sync
sequenceDiagram
participant C as Client (Offline)
participant S as Sync Service
participant DB as Message Store
participant Q as Offline Message Queue
Note over C: User comes online
C->>S: Sync request (last_seen_message_id)
S->>DB: Get messages since last_seen
DB-->>S: New messages batch
S-->>C: Batch of new messages
loop For each message
C->>C: Display message
C->>S: Ack message delivery
end
S->>Q: Mark messages as delivered
Offline Message Queue
C#
public class OfflineMessageQueue
{
private readonly IConnectionMultiplexer _redis;
// When a message is sent to an offline user, queue it
public async Task EnqueueForOfflineUserAsync(string userId, ChatMessage message)
{
var db = _redis.GetDatabase();
var entry = new OfflineMessageEntry
{
MessageId = message.Id,
ConversationId = message.ConversationId,
SenderId = message.SenderId,
Content = message.Content,
CreatedAt = message.CreatedAt,
QueuedAt = DateTime.UtcNow
};
await db.ListLeftPushAsync(
$"offline_queue:{userId}",
JsonSerializer.Serialize(entry));
// Set TTL (7 days for offline messages)
await db.KeyExpireAsync($"offline_queue:{userId}", TimeSpan.FromDays(7));
}
// When user comes online, drain the queue
public async Task<List<ChatMessage>> DrainQueueAsync(string userId, int batchSize = 100)
{
var db = _redis.GetDatabase();
var messages = new List<ChatMessage>();
for (int i = 0; i < batchSize; i++)
{
var entry = await db.ListRightPopAsync($"offline_queue:{userId}");
if (entry.IsNullOrEmpty) break;
var offlineMsg = JsonSerializer.Deserialize<OfflineMessageEntry>(entry.ToString());
messages.Add(new ChatMessage
{
Id = offlineMsg.MessageId,
ConversationId = offlineMsg.ConversationId,
SenderId = offlineMsg.SenderId,
Content = offlineMsg.Content,
CreatedAt = offlineMsg.CreatedAt
});
}
return messages;
}
}
Client Sync Strategy
| Scenario | Strategy | Details |
| Cold start | Full sync | Download last 50 messages per active conversation |
| App background ? foreground | Incremental sync | Fetch messages since last_sync_token |
| Network drop ? reconnect | Delta sync | Fetch only missed messages using sequence numbers |
| Multi-device | Shared sync state | Server tracks per-device sync position |
16. Scaling WebSocket Connections
Horizontal Scaling Pattern
flowchart TB
subgraph LB["Load Balancer"]
L1[NGINX L7]
end
subgraph WS["WebSocket Servers (1000 nodes)"]
W1[WS-1]
W2[WS-2]
W3[WS-N]
end
subgraph Redis["Connection Registry"]
R1[Redis: user_id ? server_id]
R2[Redis: server_id ? [connection_ids]]
end
subgraph Kafka["Message Bus"]
K1[conversation:{id} topics]
end
L1 --> W1 & W2 & W3
W1 & W2 & W3 --> R1
W1 & W2 & W3 --> K1
Scaling Numbers
| Metric | Per Server | Cluster (1000 servers) |
| WebSocket connections | 100K | 100M |
| Messages per second | 10K | 10M |
| CPU usage | 60% at peak | 24 cores total (at peak) |
| Memory usage | 4GB (connection state) | 4TB total |
| Network bandwidth | 1 Gbps | 1 Tbps total |
Consistent Hashing for Routing
C#
public class ConsistentHashRouter
{
private readonly SortedDictionary<uint, string> _ring = new();
private readonly int _virtualNodes = 150;
public void AddServer(string serverId)
{
for (int i = 0; i < _virtualNodes; i++)
{
var hash = ComputeHash($"{serverId}:vn{i}");
_ring[hash] = serverId;
}
}
public void RemoveServer(string serverId)
{
for (int i = 0; i < _virtualNodes; i++)
{
var hash = ComputeHash($"{serverId}:vn{i}");
_ring.Remove(hash);
}
}
public string GetServer(string userId)
{
if (_ring.Count == 0) throw new InvalidOperationException("No servers available");
var hash = ComputeHash(userId);
// Find the first server clockwise from the hash
var server = _ring.FirstOrDefault(kvp => kvp.Key >= hash);
if (server.Value == null)
{
server = _ring.First(); // Wrap around
}
return server.Value;
}
// Find the server responsible for a given user
public async Task RouteMessageAsync(string recipientUserId, ChatMessage message)
{
var targetServer = GetServer(recipientUserId);
// Send via internal gRPC to the target server
var channel = _grpcChannels.GetOrAdd(targetServer,
id => new Channel(id, 5000, ChannelCredentials.Insecure));
var client = new WebSocketRouter.WebSocketRouterClient(channel);
await client.DeliverMessageAsync(new DeliveryRequest
{
UserId = recipientUserId,
Message = message
});
}
}
17. Reliability & Delivery Guarantees
Message Delivery States
stateDiagram-v2
[*] --> Pending: Client sends
Pending --> Sent: Server acknowledges
Sent --> Delivered: Recipient receives
Delivered --> Read: Recipient views
Sent --> Failed: Delivery failed
Failed --> Sent: Retry succeeded
At-Least-Once Delivery
C#
public class ReliableMessageDelivery
{
private readonly IConnectionMultiplexer _redis;
public async Task<DeliveryResult> DeliverWithAckAsync(ChatMessage message, string recipientUserId)
{
var deliveryId = Guid.NewGuid().ToString();
var db = _redis.GetDatabase();
// Store pending delivery
await db.HashSetAsync($"pending_deliveries:{recipientUserId}",
deliveryId,
JsonSerializer.Serialize(new PendingDelivery
{
MessageId = message.Id,
DeliveryId = deliveryId,
Attempts = 0,
CreatedAt = DateTime.UtcNow
}));
// Send to recipient
var sent = await _connectionRouter.SendToUserAsync(recipientUserId, message);
if (!sent)
{
// Recipient not connected � queue for later delivery
await _offlineQueue.EnqueueForOfflineUserAsync(recipientUserId, message);
await db.HashDeleteAsync($"pending_deliveries:{recipientUserId}", deliveryId);
return DeliveryResult.QueuedForLaterDelivery;
}
// Wait for ACK with timeout
var ackReceived = await WaitForAckAsync(deliveryId, TimeSpan.FromSeconds(30));
await db.HashDeleteAsync($"pending_deliveries:{recipientUserId}", deliveryId);
return ackReceived ? DeliveryResult.Delivered : DeliveryResult.Timeout;
}
// Retry failed deliveries
public async Task RetryFailedDeliveriesAsync()
{
var db = _redis.GetDatabase();
var servers = _connectionRouter.GetAllServers();
foreach (var serverId in servers)
{
var pendingKeys = await db.HashGetAllAsync($"pending_deliveries:*");
foreach (var entry in pendingKeys)
{
var delivery = JsonSerializer.Deserialize<PendingDelivery>(entry.Value);
if (delivery.Attempts < 3 && DateTime.UtcNow - delivery.CreatedAt > TimeSpan.FromSeconds(30))
{
delivery.Attempts++;
// Retry delivery
await db.HashSetAsync(
$"pending_deliveries:{entry.Name}",
delivery.DeliveryId,
JsonSerializer.Serialize(delivery));
}
else if (delivery.Attempts >= 3)
{
// Move to dead letter queue
await MoveToDeadLetterAsync(delivery);
}
}
}
}
}
Message Ordering Guarantee
How ordering is maintained: Each conversation uses Kafka partitioning with conversation_id as the key. Within a partition, messages are strictly ordered. Cassandra uses timeuuid clustering to maintain insertion order. The client uses sequence numbers to handle out-of-order delivery from multiple servers and reorders before display.
18. Push Notifications for Chat
| Scenario | Notification Strategy | Content |
| 1-on-1 message, recipient offline | Push notification | "Alice: Hey, are you free tomorrow?" |
| Group message, recipient offline | Push notification | "[Engineering] Bob: Deploy is done" |
| Mentioned in channel | High-priority push | "@you in #general: What do you think?" |
| Direct message, recipient online | No push (delivered via WebSocket) | N/A |
| Large group (500+ members) | Topic-based push only for mentions | "@you mentioned in Large Group" |
C#
public class ChatNotificationService
{
public async Task MaybeSendPushAsync(ChatMessage message, string recipientId)
{
// Check if recipient is online
var isOnline = await _presenceService.IsOnlineAsync(recipientId);
if (isOnline) return; // Don't send push if online
// Check notification preferences
var prefs = await _preferenceService.GetAsync(recipientId);
if (!prefs.PushEnabled) return;
// Check if conversation is muted
var memberInfo = await _conversationRepo.GetMemberAsync(
message.ConversationId, recipientId);
if (memberInfo.NotificationSetting == "muted") return;
// Generate notification content
var content = await GenerateNotificationContentAsync(message, recipientId);
// Send push notification
await _notificationService.SendAsync(new NotificationRequest
{
UserId = recipientId,
Channel = "push",
Priority = message.MentionedUsers?.Contains(recipientId) == true ? "high" : "normal",
TemplateName = "chat_message",
Variables = new Dictionary<string, object>
{
["sender_name"] = content.SenderName,
["message_preview"] = content.Preview,
["conversation_name"] = content.ConversationName,
["conversation_id"] = message.ConversationId
}
});
}
}
19. Content Moderation
Moderation Pipeline
flowchart TB
A[Message Sent] --> B[Real-time Filter]
B --> C{Blocked?}
C -->|Yes| D[Reject Message]
C -->|No| E[Deliver Message]
E --> F[Async ML Analysis]
F --> G{Flagged?}
G -->|Yes| H[Queue for Human Review]
G -->|No| I[Pass]
H --> J{Confirmed Violation?}
J -->|Yes| K[Remove Message + Warn User]
Moderation Rules
| Rule | Type | Action | Latency |
| Spam detection | ML model (real-time) | Block + warn | < 50ms |
| Profanity filter | Regex + ML | Block or mask | < 10ms |
| Phishing links | URL reputation service | Block | < 100ms |
| CSAM detection | PhotoDNA + ML | Block + report to NCMEC | Async |
| Hate speech | ML model (async) | Flag for review | Async |
| Reported messages | User reports | Queue for human review | Async |
20. Monitoring & Observability
Key Metrics Dashboard
| Panel | Metric | Visualization | Alert Threshold |
| Messages/sec | Total messages across all conversations | Time series | Drop > 50% |
| WebSocket connections | Active connections per server | Gauge per server | > 120K per server |
| Message latency (p99) | End-to-end delivery time | Histogram | > 500ms |
| Delivery failure rate | Failed deliveries / total | Time series | > 0.1% |
| Kafka consumer lag | Messages behind per topic | Time series | > 50K |
| Offline queue size | Messages queued for offline users | Time series | > 10M |
| Cassandra latency | Write/read latency per operation | Histogram | > 10ms write |
| Connection churn | Connections per minute | Time series | Spike > 3x normal |
Alerting Rules
| Alert | Condition | Severity | Response |
| Message delivery stopped | 0 messages delivered for 60 seconds | P0 | Page on-call, check Kafka + Chat Service |
| WebSocket server crash | Server disconnects > 50K connections | P1 | Auto-reconnect, notify ops |
| Message ordering violation | Detect out-of-order messages | P1 | Investigate Kafka partitioning |
| Offline queue growing | Queue size doubles in 1 hour | P2 | Scale notification service |
21. Case Studies � Production Systems
WhatsApp Architecture
| Aspect | Implementation |
| Volume | 100 billion messages/day, 2B users |
| Language | Erlang (BEAM VM) � handles 2M connections per server |
| Storage | Mnesia (Erlang DB) for message queue, Cassandra for history |
| Encryption | Signal Protocol (E2E encryption) |
| Architecture | Single-server handles full conversation lifecycle |
| Key innovation | Erlang's lightweight processes (2M per server) |
Discord Architecture
| Aspect | Implementation |
| Volume | 4 billion messages/day, 150M MAU |
| Language | Elixir (Erlang-based) + Rust |
| Storage | Cassandra for messages, PostgreSQL for metadata |
| Real-time | Custom WebSocket protocol with rate limiting |
| Key innovation | Guild-based sharding for server isolation |
| Voice | WebRTC with custom SFU (Selective Forwarding Unit) |
Slack Architecture
| Aspect | Implementation |
| Volume | 1.5 billion messages/day, 20M DAU |
| Language | Java, Go, JavaScript |
| Storage | Vitess (MySQL sharding) + Cassandra |
| Real-time | Custom WebSocket with long-polling fallback |
| Key innovation | Channel-based workspace model |
| Search | Elasticsearch for full-text message search |
22. Cost Estimation
Monthly Cost (500M DAU, 20B messages/day)
| Component | Specification | Monthly Cost |
| WebSocket servers (1000) | c5.2xlarge (8 vCPU, 16GB) | $138,240 |
| Chat service (200) | c5.xlarge (4 vCPU, 8GB) | $27,648 |
| Cassandra cluster (50 nodes) | i3.2xlarge (8 vCPU, 61GB SSD) | $74,400 |
| PostgreSQL (multi-AZ) | db.r5.2xlarge (8 vCPU, 64GB) | $5,400 |
| Redis cluster (20 nodes) | r5.xlarge (4 vCPU, 26GB) | $11,520 |
| Kafka cluster (12 brokers) | k5.2xlarge | $25,920 |
| Elasticsearch (10 nodes) | r5.large.elasticsearch | $6,900 |
| S3 + CloudFront (media) | 900TB/year storage + 100TB transfer | $15,000 |
| Push notifications | 1B push/month | $0 (FCM + APNs) |
| Load balancers | ALB � 10 | $3,600 |
| Total | | ~$308,628/month |
23. Edge Cases & Corner Cases
| Edge Case | Problem | Solution |
| Message sent to deleted account | Delivery fails silently | Check account existence, return error to sender |
| User in 500+ groups | Memory pressure on connection server | Lazy-load group memberships, paginate delivery |
| Message ordering during failover | Messages arrive out of order | Client-side reordering buffer, server-side sequence numbers |
| Rapid group creation + messaging | Messages arrive before group is fully created | Group must be fully persisted before accepting messages |
| User connected on 5 devices | Message delivered 5 times | Deliver to user, not device; client deduplicates |
| Timezone differences in "last seen" | User sees "last seen 3 hours ago" but it was 5 minutes ago in their TZ | Show relative time, not absolute |
| Emoji-only message | Unicode encoding issues in SMS fallback | Test with complex Unicode, handle encoding properly |
| 10K member group with 1K messages/sec | Message flood, notification storm | Rate limit per user, batch notifications, suppress during active viewing |
| Message edited concurrently from two devices | Conflict, last write wins | Operational transform or CRDT for collaborative editing |
| Kafka partition rebalance | Temporary message reordering | Client-side sequence numbers handle reordering |
24. Interview Q&A
Q1: How would you design a real-time chat system like WhatsApp?
Start with the high-level architecture: WebSocket servers for persistent connections, Kafka for message bus, Cassandra for message storage, Redis for presence and connection routing. The key challenge is routing messages to the correct WebSocket server � use consistent hashing or a connection registry in Redis. For message delivery, publish to Kafka and let the Chat Service route to the appropriate WebSocket server based on the recipient's connection.
Q2: How do you ensure messages are delivered in order?
Within a conversation, use conversation_id as the Kafka partition key, ensuring all messages for a conversation go to the same partition in order. In Cassandra, use timeuuid clustering for insertion order. On the client side, use sequence numbers to handle out-of-order delivery from multiple servers and reordering buffer before display. For concurrent edits, use operational transform or last-writer-wins depending on the use case.
Q3: How do you handle a user being connected from multiple devices?
Store a mapping of userId ? list of (serverId, connectionId) in Redis. When delivering a message, send to all connected devices. Each device runs its own sync state independently. Use per-device sync tokens for message history. Deduplication happens at the client level using message IDs � if the same message arrives on two devices, the client ignores the duplicate.
Q4: How would you handle a group with 100K members?
Do not fan-out to all 100K members. Instead, use a subscription-based model: members subscribe to the group channel, and messages are published to a Kafka topic. Only online subscribers' WebSocket servers consume from that topic. For offline members, store the message and let them catch up via sync when they come online. Use topic-based push notifications only for mentions, not for every message.
Q5: How do you handle WebSocket server failures?
When a WebSocket server crashes, all connected clients lose their connection. The client should implement automatic reconnection with exponential backoff. On reconnect, the client re-authenticates and the server re-establishes the connection. The client then syncs any messages it missed using the last known message ID. The connection registry in Redis is updated automatically when the server heartbeat stops. Lost connections are detected by the health monitor within 90 seconds.
Q6: What are the trade-offs of E2E encryption?
E2E encryption provides strong privacy (server cannot read messages) but sacrifices: (1) server-side search � must use client-side indexing or unencrypted metadata, (2) content moderation � cannot detect spam, abuse, or illegal content, (3) multi-device sync � must share encryption keys across devices, (4) notification previews � push notifications cannot show message content. Most platforms (WhatsApp, Signal) accept these trade-offs; enterprise platforms (Slack, Teams) typically do not use E2E encryption.
25. Conclusion
Designing a real-time chat system requires balancing three fundamental tensions: real-time delivery vs scalability, message reliability vs latency, and privacy vs functionality. The architecture choices you make � WebSocket servers for persistence, Kafka for message bus, Cassandra for storage, Redis for state � directly determine which trade-offs you can make.
The key insights for your next interview:
- Stateful connections require careful routing: Unlike stateless HTTP, you must know which server each user is connected to. Redis-based connection registry with consistent hashing is the industry standard.
- Message ordering is per-conversation, not global: Each conversation is an independent stream, ordered within itself. This simplifies the architecture significantly.
- Offline support is not optional: The offline message queue and sync protocol are essential for mobile users with unreliable connections.
- Group chat scales differently than 1-on-1: Large groups require subscription-based delivery, not fan-out. This is a critical distinction for interviewers.
- Monitoring is everything: Message delivery latency, connection churn, and consumer lag are the metrics that tell you when things are going wrong.
The chat system design question is one of the richest system design problems because it touches nearly every aspect of distributed systems: persistent connections, message queues, state management, conflict resolution, encryption, and real-time delivery. Mastering it demonstrates deep understanding of distributed systems principles.