system-design29 min read

How to Design a Real-Time Chat System — A Senior+ Guide | Ayodhyya

How to Design a Real-Time Chat System

From WebSocket connections to message persistence: building WhatsApp, Slack, and Discord at scale

Senior+ Guide 45+ min read 10,000+ words Ayodhyya

Table of Contents

  1. Introduction — The Chat System Landscape
  2. Functional & Non-Functional Requirements
  3. Capacity Estimation & Back-of-Envelope
  4. Data Model & Storage Schema
  5. API Design
  6. High-Level Architecture
  7. WebSocket Connection Management
  8. Message Delivery Pipeline
  9. Presence & Online Status
  10. Group Chat & Channels
  11. Media & File Sharing
  12. Message Search
  13. Read Receipts & Typing Indicators
  14. End-to-End Encryption
  15. Offline Support & Sync
  16. Scaling WebSocket Connections
  17. Reliability & Delivery Guarantees
  18. Push Notifications for Chat
  19. Content Moderation
  20. Monitoring & Observability
  21. Case Studies — Production Systems
  22. Cost Estimation
  23. Edge Cases
  24. Interview Q&A
  25. Conclusion

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

#RequirementPriorityDetails
F11-on-1 messagingMustSend and receive messages between two users in real-time
F2Group messagingMustCreate groups, add/remove members, send to all
F3Message historyMustPersistent storage with searchable history
F4Online presenceMustShow online/offline/last seen status
F5Read receiptsShouldDelivered and read indicators
F6Typing indicatorsShould"User is typing..." display
F7Media sharingShouldImages, videos, files, voice messages
F8Push notificationsMustNotify offline users of new messages
F9Message searchNiceFull-text search across message history
F10End-to-end encryptionNiceSignal protocol for private conversations

Non-Functional Requirements

RequirementTargetRationale
Message latency< 200ms for online usersReal-time feel, competitive with WhatsApp
Throughput1 million messages/secondScale for 500M daily active users
Concurrent connections10 million per regionPeak concurrent users during events
Message orderingStrict per-conversationMessages must appear in correct order
Delivery guaranteeAt-least-onceMessages cannot be lost
Message retentionIndefinite (with limits)WhatsApp: unlimited, Slack: free tier 90 days
Availability99.99%Chat is communication infrastructure

3. Capacity Estimation & Back-of-Envelope

Daily Volume Estimates

MetricCalculationResult
Daily active usersGiven500 million
Messages per user per day4020 billion messages/day
Average message size100 bytes (text) + 200 bytes (metadata)300 bytes
Daily message data20B × 300 bytes6 TB/day
Peak QPS (10x average)20B / 86400 × 10~2.3M messages/second
Average QPS20B / 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

MetricValueNotes
Peak concurrent connections100 million~20% of DAU online simultaneously
Connections per server100KWith 2 CPU cores, 8GB RAM
WebSocket servers needed1,000100M / 100K connections each
Connection state per user500 bytesUser ID, server ID, device info
Total connection state50 GBFits 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?

RequirementCassandra StrengthPostgreSQL Weakness
Write throughputMillions of writes/secLimited by WAL and vacuum
Time-series dataNatural clustering by timePartition pruning issues at scale
Horizontal scalingAdd nodes linearlyVertical scaling with read replicas
Multi-regionBuilt-in replicationComplex logical replication
Query patternsOptimized for partition readsFlexible 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

ComponentResponsibilityScaling
WebSocket ServersMaintain persistent connections, route messagesHorizontal (stateless routing via consistent hashing)
Chat ServiceMessage processing, delivery, persistenceHorizontal (Kafka consumers)
Presence ServiceOnline/offline status, last seen trackingRedis-backed, horizontally scalable
REST APIConversation CRUD, member management, authStandard horizontal scaling
Media ServiceUpload, download, thumbnail generationCDN-backed, auto-scaling
Search ServiceFull-text message searchElasticsearch cluster scaling
Notification ServicePush notifications for offline usersDecoupled 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

StrategyHow It WorksProsCons
RandomConnect to random serverSimple, evenly distributedNo locality, no affinity
Round-robinCycle through serversEven distributionDoesn't account for load
Consistent hashingUser hash maps to serverMinimal disruption on scaleMay be uneven with few nodes
Least connectionsRoute to server with fewest connectionsBalanced loadRequires load awareness at LB
Zone-awareRoute to same geographic zoneLowest latencyComplex 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 RequirementMechanismGuarantee
Within a conversationCassandra clustering key + Kafka partitioningStrict ordering
Across conversationsNot requiredIndependent ordering
Client-side renderingSequence numbers + client bufferDisplay ordering
Conflict resolutionLast-writer-wins or operational transformEventual 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

StrategyAccuracyScalabilityCostUsed By
Redis TTL-based~5 secondsHighLowWhatsApp, Telegram
Database polling~30 secondsMediumMediumSlack (for last seen)
Pub/sub broadcastInstantLow (fan-out)HighDiscord (online status)
Gossip protocol~10 secondsVery highLowCassandra-native systems

10. Group Chat & Channels

Group Size Considerations

PlatformMax Group SizeMax Channel SizeDelivery Strategy
WhatsApp1,024N/APush to all members
Telegram200K (supergroup)Unlimited subscribersTopic-based fan-out
DiscordN/AUnlimitedChannel subscription model
SlackN/AUnlimitedWorkspace + channel model
Facebook Messenger250N/APush 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") }
                }
            });
    }
}
            

11. Media & File Sharing

flowchart TB A[Client] --> B[Media Service: Upload] B --> C[Generate signed URL] C --> D[Client uploads to S3] D --> E[S3 Event Notification] E --> F[Thumbnail Worker] F --> G[S3: thumbnails bucket] B --> H[Store media metadata in DB] H --> I[Send media message via chat pipeline]

Media Processing Pipeline

Media TypeProcessingStorageCDN
ImageResize (thumbnail, medium, full), WebP conversionS3 + 3 variantsCloudFront
VideoTranscode (360p, 720p, 1080p), generate previewS3 + 3 variantsCloudFront
AudioNormalize, generate waveform visualizationS3CloudFront
FileScan for malware, generate preview if possibleS3CloudFront (with auth)
Voice messageTranscode to opus, generate waveformS3CloudFront
C#
public class MediaService
{
    private readonly IS3Client _s3;
    private readonly IImageProcessor _imageProcessor;
    private readonly IVideoProcessor _videoProcessor;

    public async Task<MediaUploadResult> UploadAsync(MediaUploadRequest request)
    {
        // Generate unique media ID
        var mediaId = Guid.NewGuid().ToString();

        // Store original in S3
        var originalKey = $"media/{mediaId}/original/{request.FileName}";
        await _s3.PutObjectAsync(new PutObjectRequest
        {
            BucketName = _config.MediaBucket,
            Key = originalKey,
            InputStream = request.FileStream,
            ContentType = request.ContentType
        });

        // Process based on media type
        var variants = new List<MediaVariant>();

        if (request.ContentType.StartsWith("image/"))
        {
            variants = await ProcessImageAsync(mediaId, request);
        }
        else if (request.ContentType.StartsWith("video/"))
        {
            variants = await ProcessVideoAsync(mediaId, request);
        }

        // Store metadata
        var metadata = new MediaMetadata
        {
            Id = mediaId,
            OriginalKey = originalKey,
            FileName = request.FileName,
            ContentType = request.ContentType,
            FileSize = request.FileStream.Length,
            Variants = variants,
            UploadedBy = request.UserId,
            UploadedAt = DateTime.UtcNow
        };

        await _mediaRepo.SaveAsync(metadata);

        return new MediaUploadResult
        {
            MediaId = mediaId,
            Url = GetCdnUrl(originalKey),
            Variants = variants.Select(v => new MediaVariantResult
            {
                Url = GetCdnUrl(v.Key),
                Width = v.Width,
                Height = v.Height,
                FileSize = v.FileSize
            }).ToList()
        };
    }

    private async Task<List<MediaVariant>> ProcessImageAsync(string mediaId, MediaUploadRequest request)
    {
        var variants = new List<MediaVariant>();

        var sizes = new[] { (150, 150, "thumbnail"), (800, 600, "medium") };

        foreach (var (width, height, label) in sizes)
        {
            var resized = await _imageProcessor.ResizeAsync(request.FileStream, width, height);
            var key = $"media/{mediaId}/{label}/{label}.webp";

            await _s3.PutObjectAsync(new PutObjectRequest
            {
                BucketName = _config.MediaBucket,
                Key = key,
                InputStream = resized,
                ContentType = "image/webp"
            });

            variants.Add(new MediaVariant
            {
                Key = key,
                Width = width,
                Height = height,
                Label = label
            });
        }

        return variants;
    }
}
            

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

ComponentPurposeImplementation
Double RatchetForward secrecy for each messageEach message uses a new ephemeral key
X3DH Key AgreementAsynchronous key exchangePre-key bundles for offline users
AES-256-GCMSymmetric encryption of message contentAuthenticated encryption
HMAC-SHA256Message integrity verificationDetect 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

ScenarioStrategyDetails
Cold startFull syncDownload last 50 messages per active conversation
App background ? foregroundIncremental syncFetch messages since last_sync_token
Network drop ? reconnectDelta syncFetch only missed messages using sequence numbers
Multi-deviceShared sync stateServer 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

MetricPer ServerCluster (1000 servers)
WebSocket connections100K100M
Messages per second10K10M
CPU usage60% at peak24 cores total (at peak)
Memory usage4GB (connection state)4TB total
Network bandwidth1 Gbps1 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

ScenarioNotification StrategyContent
1-on-1 message, recipient offlinePush notification"Alice: Hey, are you free tomorrow?"
Group message, recipient offlinePush notification"[Engineering] Bob: Deploy is done"
Mentioned in channelHigh-priority push"@you in #general: What do you think?"
Direct message, recipient onlineNo 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

RuleTypeActionLatency
Spam detectionML model (real-time)Block + warn< 50ms
Profanity filterRegex + MLBlock or mask< 10ms
Phishing linksURL reputation serviceBlock< 100ms
CSAM detectionPhotoDNA + MLBlock + report to NCMECAsync
Hate speechML model (async)Flag for reviewAsync
Reported messagesUser reportsQueue for human reviewAsync

20. Monitoring & Observability

Key Metrics Dashboard

PanelMetricVisualizationAlert Threshold
Messages/secTotal messages across all conversationsTime seriesDrop > 50%
WebSocket connectionsActive connections per serverGauge per server> 120K per server
Message latency (p99)End-to-end delivery timeHistogram> 500ms
Delivery failure rateFailed deliveries / totalTime series> 0.1%
Kafka consumer lagMessages behind per topicTime series> 50K
Offline queue sizeMessages queued for offline usersTime series> 10M
Cassandra latencyWrite/read latency per operationHistogram> 10ms write
Connection churnConnections per minuteTime seriesSpike > 3x normal

Alerting Rules

AlertConditionSeverityResponse
Message delivery stopped0 messages delivered for 60 secondsP0Page on-call, check Kafka + Chat Service
WebSocket server crashServer disconnects > 50K connectionsP1Auto-reconnect, notify ops
Message ordering violationDetect out-of-order messagesP1Investigate Kafka partitioning
Offline queue growingQueue size doubles in 1 hourP2Scale notification service

21. Case Studies � Production Systems

WhatsApp Architecture

AspectImplementation
Volume100 billion messages/day, 2B users
LanguageErlang (BEAM VM) � handles 2M connections per server
StorageMnesia (Erlang DB) for message queue, Cassandra for history
EncryptionSignal Protocol (E2E encryption)
ArchitectureSingle-server handles full conversation lifecycle
Key innovationErlang's lightweight processes (2M per server)

Discord Architecture

AspectImplementation
Volume4 billion messages/day, 150M MAU
LanguageElixir (Erlang-based) + Rust
StorageCassandra for messages, PostgreSQL for metadata
Real-timeCustom WebSocket protocol with rate limiting
Key innovationGuild-based sharding for server isolation
VoiceWebRTC with custom SFU (Selective Forwarding Unit)

Slack Architecture

AspectImplementation
Volume1.5 billion messages/day, 20M DAU
LanguageJava, Go, JavaScript
StorageVitess (MySQL sharding) + Cassandra
Real-timeCustom WebSocket with long-polling fallback
Key innovationChannel-based workspace model
SearchElasticsearch for full-text message search

22. Cost Estimation

Monthly Cost (500M DAU, 20B messages/day)

ComponentSpecificationMonthly 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 notifications1B push/month$0 (FCM + APNs)
Load balancersALB � 10$3,600
Total~$308,628/month

23. Edge Cases & Corner Cases

Edge CaseProblemSolution
Message sent to deleted accountDelivery fails silentlyCheck account existence, return error to sender
User in 500+ groupsMemory pressure on connection serverLazy-load group memberships, paginate delivery
Message ordering during failoverMessages arrive out of orderClient-side reordering buffer, server-side sequence numbers
Rapid group creation + messagingMessages arrive before group is fully createdGroup must be fully persisted before accepting messages
User connected on 5 devicesMessage delivered 5 timesDeliver 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 TZShow relative time, not absolute
Emoji-only messageUnicode encoding issues in SMS fallbackTest with complex Unicode, handle encoding properly
10K member group with 1K messages/secMessage flood, notification stormRate limit per user, batch notifications, suppress during active viewing
Message edited concurrently from two devicesConflict, last write winsOperational transform or CRDT for collaborative editing
Kafka partition rebalanceTemporary message reorderingClient-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.

© 2025 Ayodhyya Blog Series � All rights reserved.