Questions

Design Chat Application

Learn how to design a scalable real-time chat application with modern frontend architecture

Design Chat Application

Learn how to architect a scalable real-time chat application with modern frontend patterns, WebSocket communication, and performance optimization.


Problem Statement

Design a real-time chat application similar to WhatsApp, Slack, or Discord with the following capabilities:

  • Real-time messaging with instant delivery
  • Message persistence and history
  • User presence indicators (online/offline)
  • Typing indicators and read receipts
  • File sharing and media support
  • Group conversations and channels
  • Message search and filtering
  • Offline support with message queuing
  • Push notifications for new messages

Clarifying Questions

Functional Requirements

  • User Scale: How many concurrent users do we need to support?
  • Message Volume: What's the expected message frequency?
  • Media Support: What types of files should be supported (images, videos, documents)?
  • Group Size: What's the maximum number of participants in a group chat?
  • Message Retention: How long should messages be stored?
  • Search Capabilities: Should search be real-time or indexed?

Technical Constraints

  • Platform Support: Web, mobile, or both?
  • Browser Support: Which browsers and versions?
  • Network Conditions: How should it handle poor connectivity?
  • Security: What level of encryption is required?
  • Compliance: Any data privacy requirements (GDPR, etc.)?

High-Level Architecture

1. Frontend Architecture

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Chat UI       │    │  State Manager  │    │  WebSocket      │
│   Components    │◄──►│  (Redux/Zustand)│◄──►│  Connection     │
└─────────────────┘    └─────────────────┘    └─────────────────┘
         │                       │                       │
         ▼                       ▼                       ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Message List  │    │   Cache Layer   │    │   API Client    │
│   (Virtualized) │    │   (IndexedDB)   │    │   (REST/GraphQL)│
└─────────────────┘    └─────────────────┘    └─────────────────┘

2. Core Components

Message List Component

  • Virtual Scrolling for performance with large message histories
  • Infinite Scroll for loading older messages
  • Message Grouping by date and sender
  • Optimistic Updates for immediate feedback

Message Input Component

  • Rich Text Editor with emoji support
  • File Upload with drag-and-drop
  • Typing Indicators and character limits
  • Message Drafts and auto-save

User Interface Components

  • User List with presence indicators
  • Channel/Group Management
  • Search Interface with filters
  • Settings and Preferences

Real-Time Communication

WebSocket Implementation

interface WebSocketMessage {
  type: 'message' | 'typing' | 'presence' | 'read_receipt';
  payload: any;
  timestamp: number;
  messageId?: string;
}

class ChatWebSocket {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;
  private reconnectDelay = 1000;
  private messageQueue: WebSocketMessage[] = [];

  constructor(private url: string, private token: string) {}

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(`${this.url}?token=${this.token}`);
      
      this.ws.onopen = () => {
        console.log('WebSocket connected');
        this.reconnectAttempts = 0;
        this.flushMessageQueue();
        resolve();
      };

      this.ws.onmessage = (event) => {
        const message: WebSocketMessage = JSON.parse(event.data);
        this.handleMessage(message);
      };

      this.ws.onclose = () => {
        console.log('WebSocket disconnected');
        this.handleReconnect();
      };

      this.ws.onerror = (error) => {
        console.error('WebSocket error:', error);
        reject(error);
      };
    });
  }

  private handleMessage(message: WebSocketMessage) {
    switch (message.type) {
      case 'message':
        this.handleNewMessage(message.payload);
        break;
      case 'typing':
        this.handleTypingIndicator(message.payload);
        break;
      case 'presence':
        this.handlePresenceUpdate(message.payload);
        break;
      case 'read_receipt':
        this.handleReadReceipt(message.payload);
        break;
    }
  }

  sendMessage(message: WebSocketMessage) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      this.messageQueue.push(message);
    }
  }

  private flushMessageQueue() {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift();
      if (message) {
        this.sendMessage(message);
      }
    }
  }

  private handleReconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      setTimeout(() => {
        this.connect().catch(console.error);
      }, this.reconnectDelay * this.reconnectAttempts);
    }
  }
}

Message Handling

interface Message {
  id: string;
  content: string;
  senderId: string;
  senderName: string;
  timestamp: Date;
  type: 'text' | 'image' | 'file' | 'system';
  status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
  replyTo?: string;
  attachments?: Attachment[];
}

interface Attachment {
  id: string;
  name: string;
  url: string;
  type: string;
  size: number;
  thumbnail?: string;
}

class MessageHandler {
  private store: ChatStore;

  constructor(store: ChatStore) {
    this.store = store;
  }

  handleNewMessage(messageData: Message) {
    // Add to store with optimistic update
    this.store.addMessage(messageData);
    
    // Update unread count
    this.store.incrementUnreadCount(messageData.chatId);
    
    // Show notification if app is not focused
    if (!document.hasFocus()) {
      this.showNotification(messageData);
    }
  }

  handleTypingIndicator(data: { userId: string; chatId: string; isTyping: boolean }) {
    this.store.setTypingIndicator(data.chatId, data.userId, data.isTyping);
  }

  handlePresenceUpdate(data: { userId: string; status: 'online' | 'offline' | 'away' }) {
    this.store.updateUserPresence(data.userId, data.status);
  }

  handleReadReceipt(data: { messageId: string; readBy: string; timestamp: Date }) {
    this.store.updateMessageStatus(data.messageId, 'read', data.readBy);
  }

  private showNotification(message: Message) {
    if ('Notification' in window && Notification.permission === 'granted') {
      new Notification(`New message from ${message.senderName}`, {
        body: message.content,
        icon: '/icon.png',
      });
    }
  }
}

State Management

Redux Store Structure

interface ChatState {
  conversations: {
    [chatId: string]: Conversation;
  };
  messages: {
    [chatId: string]: Message[];
  };
  users: {
    [userId: string]: User;
  };
  currentChat: string | null;
  typingIndicators: {
    [chatId: string]: Set<string>;
  };
  unreadCounts: {
    [chatId: string]: number;
  };
  connectionStatus: 'connected' | 'connecting' | 'disconnected';
}

interface Conversation {
  id: string;
  name: string;
  type: 'direct' | 'group';
  participants: string[];
  lastMessage?: Message;
  unreadCount: number;
  isArchived: boolean;
}

interface User {
  id: string;
  name: string;
  avatar: string;
  status: 'online' | 'offline' | 'away';
  lastSeen?: Date;
}

Redux Actions and Reducers

// Actions
const chatActions = {
  addMessage: createAction<Message>('chat/addMessage'),
  updateMessageStatus: createAction<{ messageId: string; status: MessageStatus; readBy?: string }>('chat/updateMessageStatus'),
  setTypingIndicator: createAction<{ chatId: string; userId: string; isTyping: boolean }>('chat/setTypingIndicator'),
  updateUserPresence: createAction<{ userId: string; status: UserStatus }>('chat/updateUserPresence'),
  markChatAsRead: createAction<string>('chat/markAsRead'),
  setConnectionStatus: createAction<ConnectionStatus>('chat/setConnectionStatus'),
};

// Reducer
const chatReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(chatActions.addMessage, (state, action) => {
      const message = action.payload;
      const chatId = message.chatId;
      
      if (!state.messages[chatId]) {
        state.messages[chatId] = [];
      }
      
      state.messages[chatId].push(message);
      
      // Update conversation last message
      if (state.conversations[chatId]) {
        state.conversations[chatId].lastMessage = message;
      }
    })
    .addCase(chatActions.updateMessageStatus, (state, action) => {
      const { messageId, status, readBy } = action.payload;
      
      // Find and update message status
      Object.values(state.messages).forEach(messages => {
        const message = messages.find(m => m.id === messageId);
        if (message) {
          message.status = status;
          if (readBy) {
            message.readBy = message.readBy || [];
            message.readBy.push(readBy);
          }
        }
      });
    })
    .addCase(chatActions.setTypingIndicator, (state, action) => {
      const { chatId, userId, isTyping } = action.payload;
      
      if (!state.typingIndicators[chatId]) {
        state.typingIndicators[chatId] = new Set();
      }
      
      if (isTyping) {
        state.typingIndicators[chatId].add(userId);
      } else {
        state.typingIndicators[chatId].delete(userId);
      }
    });
});

UI Components

Virtualized Message List

import { FixedSizeList as List } from 'react-window';

interface MessageListProps {
  messages: Message[];
  chatId: string;
  currentUserId: string;
}

const MessageList: React.FC<MessageListProps> = ({ messages, chatId, currentUserId }) => {
  const listRef = useRef<List>(null);
  const [isLoading, setIsLoading] = useState(false);

  const loadMoreMessages = useCallback(async () => {
    if (isLoading) return;
    
    setIsLoading(true);
    try {
      const olderMessages = await fetchMessages(chatId, messages[0]?.id);
      // Add messages to store
      olderMessages.forEach(message => store.dispatch(chatActions.addMessage(message)));
    } finally {
      setIsLoading(false);
    }
  }, [chatId, messages, isLoading]);

  const MessageRow = ({ index, style }: { index: number; style: CSSProperties }) => {
    const message = messages[index];
    const isOwnMessage = message.senderId === currentUserId;
    
    return (
      <div style={style}>
        <MessageBubble
          message={message}
          isOwn={isOwnMessage}
          showAvatar={shouldShowAvatar(messages, index)}
          showTimestamp={shouldShowTimestamp(messages, index)}
        />
      </div>
    );
  };

  return (
    <div className="message-list">
      {isLoading && (
        <div className="loading-indicator">
          Loading more messages...
        </div>
      )}
      
      <List
        ref={listRef}
        height={600}
        itemCount={messages.length}
        itemSize={80}
        onScroll={({ scrollOffset }) => {
          if (scrollOffset < 100) {
            loadMoreMessages();
          }
        }}
      >
        {MessageRow}
      </List>
      
      <TypingIndicator chatId={chatId} />
    </div>
  );
};

Message Input Component

interface MessageInputProps {
  chatId: string;
  onSendMessage: (content: string, attachments?: File[]) => void;
}

const MessageInput: React.FC<MessageInputProps> = ({ chatId, onSendMessage }) => {
  const [content, setContent] = useState('');
  const [attachments, setAttachments] = useState<File[]>([]);
  const [isTyping, setIsTyping] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);
  
  const debouncedTyping = useDebounce(isTyping, 1000);

  useEffect(() => {
    if (debouncedTyping) {
      // Send typing indicator
      websocket.sendMessage({
        type: 'typing',
        payload: { chatId, isTyping: true },
        timestamp: Date.now(),
      });
    }
  }, [debouncedTyping, chatId]);

  const handleSend = () => {
    if (!content.trim() && attachments.length === 0) return;
    
    onSendMessage(content, attachments);
    setContent('');
    setAttachments([]);
    setIsTyping(false);
    
    // Send stop typing indicator
    websocket.sendMessage({
      type: 'typing',
      payload: { chatId, isTyping: false },
      timestamp: Date.now(),
    });
  };

  const handleFileUpload = (files: FileList) => {
    const newAttachments = Array.from(files);
    setAttachments(prev => [...prev, ...newAttachments]);
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="message-input">
      <div className="input-container">
        <textarea
          value={content}
          onChange={(e) => {
            setContent(e.target.value);
            setIsTyping(true);
          }}
          onKeyPress={handleKeyPress}
          placeholder="Type a message..."
          rows={1}
        />
        
        <div className="attachment-preview">
          {attachments.map((file, index) => (
            <div key={index} className="attachment-item">
              <span>{file.name}</span>
              <button onClick={() => setAttachments(prev => prev.filter((_, i) => i !== index))}>
                ×
              </button>
            </div>
          ))}
        </div>
      </div>
      
      <div className="input-actions">
        <button onClick={() => fileInputRef.current?.click()}>
          📎
        </button>
        <button onClick={handleSend} disabled={!content.trim() && attachments.length === 0}>
          Send
        </button>
      </div>
      
      <input
        ref={fileInputRef}
        type="file"
        multiple
        onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
        style={{ display: 'none' }}
      />
    </div>
  );
};

Performance Optimization

Message Virtualization

const useVirtualizedMessages = (messages: Message[], containerHeight: number) => {
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
  const containerRef = useRef<HTMLDivElement>(null);

  const itemHeight = 80; // Estimated height per message
  const visibleCount = Math.ceil(containerHeight / itemHeight) + 10; // Buffer

  const updateVisibleRange = useCallback(() => {
    if (!containerRef.current) return;
    
    const scrollTop = containerRef.current.scrollTop;
    const start = Math.floor(scrollTop / itemHeight);
    const end = Math.min(start + visibleCount, messages.length);
    
    setVisibleRange({ start, end });
  }, [messages.length, visibleCount, itemHeight]);

  const visibleMessages = messages.slice(visibleRange.start, visibleRange.end);

  return {
    visibleMessages,
    containerRef,
    updateVisibleRange,
    totalHeight: messages.length * itemHeight,
    offsetY: visibleRange.start * itemHeight,
  };
};

Message Caching

class MessageCache {
  private cache = new Map<string, Message[]>();
  private maxCacheSize = 1000;

  async getMessages(chatId: string, limit: number = 50): Promise<Message[]> {
    if (this.cache.has(chatId)) {
      return this.cache.get(chatId)!.slice(-limit);
    }

    const messages = await this.fetchMessagesFromAPI(chatId, limit);
    this.cache.set(chatId, messages);
    return messages;
  }

  addMessage(chatId: string, message: Message) {
    if (!this.cache.has(chatId)) {
      this.cache.set(chatId, []);
    }

    const messages = this.cache.get(chatId)!;
    messages.push(message);

    // Keep cache size manageable
    if (messages.length > this.maxCacheSize) {
      messages.splice(0, messages.length - this.maxCacheSize);
    }
  }

  private async fetchMessagesFromAPI(chatId: string, limit: number): Promise<Message[]> {
    const response = await fetch(`/api/chats/${chatId}/messages?limit=${limit}`);
    return response.json();
  }
}

Security and Privacy

Message Encryption

class MessageEncryption {
  private keyPair: CryptoKeyPair | null = null;

  async generateKeyPair(): Promise<void> {
    this.keyPair = await window.crypto.subtle.generateKey(
      {
        name: 'RSA-OAEP',
        modulusLength: 2048,
        publicExponent: new Uint8Array([1, 0, 1]),
        hash: 'SHA-256',
      },
      true,
      ['encrypt', 'decrypt']
    );
  }

  async encryptMessage(message: string, publicKey: CryptoKey): Promise<string> {
    const encoder = new TextEncoder();
    const data = encoder.encode(message);
    
    const encrypted = await window.crypto.subtle.encrypt(
      { name: 'RSA-OAEP' },
      publicKey,
      data
    );
    
    return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
  }

  async decryptMessage(encryptedMessage: string): Promise<string> {
    if (!this.keyPair?.privateKey) {
      throw new Error('Private key not available');
    }

    const data = Uint8Array.from(atob(encryptedMessage), c => c.charCodeAt(0));
    
    const decrypted = await window.crypto.subtle.decrypt(
      { name: 'RSA-OAEP' },
      this.keyPair.privateKey,
      data
    );
    
    const decoder = new TextDecoder();
    return decoder.decode(decrypted);
  }
}

Offline Support

Service Worker for Offline Messaging

// service-worker.js
const CACHE_NAME = 'chat-app-v1';
const OFFLINE_MESSAGES_KEY = 'offline-messages';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll([
        '/',
        '/static/js/bundle.js',
        '/static/css/main.css',
      ]);
    })
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/messages')) {
    event.respondWith(
      fetch(event.request).catch(() => {
        // Return offline response
        return new Response(JSON.stringify({ offline: true }), {
          headers: { 'Content-Type': 'application/json' },
        });
      })
    );
  }
});

self.addEventListener('sync', (event) => {
  if (event.tag === 'send-offline-messages') {
    event.waitUntil(sendOfflineMessages());
  }
});

async function sendOfflineMessages() {
  const messages = await getOfflineMessages();
  
  for (const message of messages) {
    try {
      await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      });
      
      // Remove from offline storage
      await removeOfflineMessage(message.id);
    } catch (error) {
      console.error('Failed to send offline message:', error);
    }
  }
}

Best Practices

Performance

  • Virtual Scrolling for large message lists
  • Message Pagination to limit initial load
  • Image Optimization with lazy loading and compression
  • Bundle Splitting for chat features

User Experience

  • Optimistic Updates for immediate feedback
  • Typing Indicators to show user activity
  • Read Receipts for message status
  • Push Notifications for new messages

Security

  • End-to-End Encryption for sensitive messages
  • Input Sanitization to prevent XSS
  • Rate Limiting to prevent spam
  • Secure WebSocket connections

Reliability

  • Offline Support with message queuing
  • Automatic Reconnection for WebSocket
  • Message Retry for failed deliveries
  • Data Persistence with IndexedDB

Interview Tips

What Interviewers Look For:

  1. Real-time communication understanding (WebSockets, polling)
  2. Performance optimization (virtualization, caching)
  3. State management for complex chat data
  4. Offline handling and data persistence

Sample Questions:

  • "How would you handle 10,000 concurrent users?"
  • "What if WebSocket connection fails?"
  • "How would you implement message search?"
  • "How would you optimize for mobile devices?"

Key Points to Mention:

  • Start with WebSocket for real-time communication
  • Use virtualization for large message lists
  • Implement optimistic updates for better UX
  • Plan for offline scenarios with message queuing
  • Consider security with end-to-end encryption

Next: Sprint Board Design - Learn how to build a drag-and-drop task management system.