Front-end Engineering Lab

Chat Architecture

Build scalable, real-time chat systems with typing indicators, read receipts, and message ordering

Building a production-ready chat system requires handling message ordering, delivery guarantees, typing indicators, and more. This guide covers it all.

🎯 Requirements

What we need:
✅ Real-time message delivery
✅ Message ordering
✅ Typing indicators
✅ Read receipts
✅ Offline support
✅ Message history
✅ File uploads

💬 Pattern 1: Basic Chat Client

Simple real-time chat.

interface Message {
  id: string;
  conversationId: string;
  senderId: string;
  text: string;
  timestamp: number;
  status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
}

class ChatClient {
  private ws: WebSocket;
  private messageQueue: Message[] = [];
  
  constructor(private userId: string, private conversationId: string) {
    this.ws = new WebSocket(`wss://api.example.com/chat`);
    this.setupWebSocket();
  }
  
  private setupWebSocket() {
    this.ws.onopen = () => {
      // Subscribe to conversation
      this.ws.send(JSON.stringify({
        type: 'subscribe',
        conversationId: this.conversationId
      }));
      
      // Flush queued messages
      this.flushQueue();
    };
    
    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleMessage(message);
    };
  }
  
  sendMessage(text: string) {
    const message: Message = {
      id: `temp-${Date.now()}`,
      conversationId: this.conversationId,
      senderId: this.userId,
      text,
      timestamp: Date.now(),
      status: 'sending'
    };
    
    // Optimistically add to UI
    this.onMessageSent?.(message);
    
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: 'message',
        message
      }));
    } else {
      this.messageQueue.push(message);
    }
  }
  
  private flushQueue() {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift()!;
      this.ws.send(JSON.stringify({
        type: 'message',
        message
      }));
    }
  }
  
  private handleMessage(data: any) {
    switch (data.type) {
      case 'message':
        this.onMessageReceived?.(data.message);
        break;
      case 'message_ack':
        this.onMessageAck?.(data.tempId, data.messageId);
        break;
      case 'typing':
        this.onTyping?.(data.userId);
        break;
      case 'read':
        this.onRead?.(data.userId, data.messageId);
        break;
    }
  }
  
  // Event handlers
  onMessageSent?: (message: Message) => void;
  onMessageReceived?: (message: Message) => void;
  onMessageAck?: (tempId: string, realId: string) => void;
  onTyping?: (userId: string) => void;
  onRead?: (userId: string, messageId: string) => void;
}

React Component

function ChatRoom({ conversationId }: { conversationId: string }) {
  const { user } = useAuth();
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const chatClient = useRef<ChatClient>();
  
  useEffect(() => {
    const client = new ChatClient(user.id, conversationId);
    
    client.onMessageSent = (message) => {
      setMessages(prev => [...prev, message]);
    };
    
    client.onMessageReceived = (message) => {
      setMessages(prev => [...prev, message]);
    };
    
    client.onMessageAck = (tempId, realId) => {
      setMessages(prev =>
        prev.map(msg =>
          msg.id === tempId
            ? { ...msg, id: realId, status: 'sent' }
            : msg
        )
      );
    };
    
    chatClient.current = client;
    
    return () => client.close();
  }, [conversationId, user.id]);
  
  const sendMessage = () => {
    if (input.trim()) {
      chatClient.current?.sendMessage(input);
      setInput('');
    }
  };
  
  return (
    <div className="chat-room">
      <div className="messages">
        {messages.map(msg => (
          <ChatMessage key={msg.id} message={msg} isOwn={msg.senderId === user.id} />
        ))}
      </div>
      
      <div className="input-area">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="Type a message..."
        />
        <button onClick={sendMessage}>Send</button>
      </div>
    </div>
  );
}

⌨️ Pattern 2: Typing Indicators

Show when users are typing.

class TypingIndicator {
  private ws: WebSocket;
  private typingTimer: NodeJS.Timeout | null = null;
  private typingTimeout = 3000; // 3 seconds
  
  constructor(private conversationId: string, private userId: string, ws: WebSocket) {
    this.ws = ws;
  }
  
  startTyping() {
    // Clear existing timer
    if (this.typingTimer) {
      clearTimeout(this.typingTimer);
    }
    
    // Send typing event
    this.ws.send(JSON.stringify({
      type: 'typing_start',
      conversationId: this.conversationId,
      userId: this.userId
    }));
    
    // Auto-stop after timeout
    this.typingTimer = setTimeout(() => {
      this.stopTyping();
    }, this.typingTimeout);
  }
  
  stopTyping() {
    if (this.typingTimer) {
      clearTimeout(this.typingTimer);
      this.typingTimer = null;
    }
    
    this.ws.send(JSON.stringify({
      type: 'typing_stop',
      conversationId: this.conversationId,
      userId: this.userId
    }));
  }
}

// React Hook
function useTypingIndicator(conversationId: string, ws: WebSocket) {
  const { user } = useAuth();
  const typingIndicator = useRef<TypingIndicator>();
  
  useEffect(() => {
    typingIndicator.current = new TypingIndicator(conversationId, user.id, ws);
  }, [conversationId, user.id, ws]);
  
  const handleTyping = () => {
    typingIndicator.current?.startTyping();
  };
  
  const handleStopTyping = () => {
    typingIndicator.current?.stopTyping();
  };
  
  return { handleTyping, handleStopTyping };
}

// Usage in Chat Component
function ChatInput() {
  const { handleTyping, handleStopTyping } = useTypingIndicator(conversationId, ws);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  
  return (
    <div>
      {typingUsers.length > 0 && (
        <div className="typing-indicator">
          {typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
        </div>
      )}
      
      <input
        onChange={(e) => {
          if (e.target.value) {
            handleTyping();
          } else {
            handleStopTyping();
          }
        }}
        onBlur={handleStopTyping}
      />
    </div>
  );
}

✓✓ Pattern 3: Read Receipts

Track message delivery and read status.

interface MessageStatus {
  messageId: string;
  sent: Date;
  delivered?: Date;
  read?: Date;
}

class ReadReceiptManager {
  private ws: WebSocket;
  private statuses = new Map<string, MessageStatus>();
  
  constructor(ws: WebSocket) {
    this.ws = ws;
  }
  
  markMessageSent(messageId: string) {
    this.statuses.set(messageId, {
      messageId,
      sent: new Date()
    });
  }
  
  markMessageDelivered(messageId: string) {
    const status = this.statuses.get(messageId);
    if (status) {
      status.delivered = new Date();
      this.notifyStatusChange(messageId, 'delivered');
    }
  }
  
  markMessageRead(messageId: string, userId: string) {
    const status = this.statuses.get(messageId);
    if (status) {
      status.read = new Date();
      this.notifyStatusChange(messageId, 'read');
    }
    
    // Send read receipt to server
    this.ws.send(JSON.stringify({
      type: 'read_receipt',
      messageId,
      userId,
      timestamp: Date.now()
    }));
  }
  
  // Mark all visible messages as read
  markVisibleMessagesAsRead(messageIds: string[], userId: string) {
    messageIds.forEach(id => this.markMessageRead(id, userId));
  }
  
  private notifyStatusChange(messageId: string, status: string) {
    // Emit event for UI update
    window.dispatchEvent(new CustomEvent('message-status-change', {
      detail: { messageId, status }
    }));
  }
}

// React Hook with Intersection Observer
function useReadReceipts(conversationId: string, ws: WebSocket) {
  const { user } = useAuth();
  const receiptManager = useRef(new ReadReceiptManager(ws));
  
  // Track visible messages
  const handleMessageVisible = useCallback((messageId: string) => {
    receiptManager.current.markMessageRead(messageId, user.id);
  }, [user.id]);
  
  // Use Intersection Observer to track visibility
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            const messageId = entry.target.getAttribute('data-message-id');
            if (messageId) {
              handleMessageVisible(messageId);
            }
          }
        });
      },
      { threshold: 0.5 }
    );
    
    // Observe all message elements
    document.querySelectorAll('[data-message-id]').forEach(el => {
      observer.observe(el);
    });
    
    return () => observer.disconnect();
  }, [handleMessageVisible]);
  
  return receiptManager.current;
}

// Message Component with Read Receipts
function ChatMessage({ message, isOwn }: Props) {
  const ref = useRef<HTMLDivElement>(null);
  
  return (
    <div
      ref={ref}
      data-message-id={message.id}
      className={`message ${isOwn ? 'own' : 'other'}`}
    >
      <div className="message-text">{message.text}</div>
      <div className="message-meta">
        <span>{formatTime(message.timestamp)}</span>
        {isOwn && (
          <span className="status">
            {message.status === 'sending' && '⏱️'}
            {message.status === 'sent' && '✓'}
            {message.status === 'delivered' && '✓✓'}
            {message.status === 'read' && '✓✓'}
          </span>
        )}
      </div>
    </div>
  );
}

🔢 Pattern 4: Message Ordering

Ensure correct message order.

class MessageOrdering {
  private messages: Message[] = [];
  private expectedSequence = 0;
  private pendingMessages = new Map<number, Message>();
  
  addMessage(message: Message & { sequence: number }) {
    if (message.sequence === this.expectedSequence) {
      // Expected message, add it
      this.messages.push(message);
      this.expectedSequence++;
      
      // Check for pending messages
      this.processPendingMessages();
    } else if (message.sequence > this.expectedSequence) {
      // Future message, hold it
      this.pendingMessages.set(message.sequence, message);
    }
    // Ignore old messages (sequence < expectedSequence)
  }
  
  private processPendingMessages() {
    while (this.pendingMessages.has(this.expectedSequence)) {
      const message = this.pendingMessages.get(this.expectedSequence)!;
      this.messages.push(message);
      this.pendingMessages.delete(this.expectedSequence);
      this.expectedSequence++;
    }
  }
  
  getMessages(): Message[] {
    return this.messages;
  }
  
  hasMissingMessages(): boolean {
    return this.pendingMessages.size > 0;
  }
  
  requestMissingMessages() {
    if (this.pendingMessages.size > 0) {
      const minPending = Math.min(...this.pendingMessages.keys());
      // Request messages from expectedSequence to minPending
      return {
        from: this.expectedSequence,
        to: minPending - 1
      };
    }
    return null;
  }
}

📁 Pattern 5: File Upload in Chat

Send files with progress tracking.

async function uploadFile(
  file: File,
  conversationId: string,
  onProgress: (progress: number) => void
): Promise<string> {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('conversationId', conversationId);
  
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        const progress = (event.loaded / event.total) * 100;
        onProgress(progress);
      }
    };
    
    xhr.onload = () => {
      if (xhr.status === 200) {
        const response = JSON.parse(xhr.responseText);
        resolve(response.url);
      } else {
        reject(new Error('Upload failed'));
      }
    };
    
    xhr.onerror = () => reject(new Error('Upload failed'));
    
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  });
}

// React Component
function FileUploadMessage() {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  
  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    
    setUploading(true);
    
    try {
      const url = await uploadFile(file, conversationId, setProgress);
      
      // Send message with file URL
      chatClient.sendMessage({
        type: 'file',
        url,
        fileName: file.name,
        fileSize: file.size
      });
    } catch (error) {
      toast.error('Failed to upload file');
    } finally {
      setUploading(false);
      setProgress(0);
    }
  };
  
  return (
    <div>
      <input
        type="file"
        onChange={handleFileSelect}
        disabled={uploading}
      />
      {uploading && (
        <div className="upload-progress">
          <div style={{ width: `${progress}%` }} />
          <span>{Math.round(progress)}%</span>
        </div>
      )}
    </div>
  );
}

📚 Key Takeaways

  1. Optimistic updates - Add messages immediately
  2. Message ordering - Use sequence numbers
  3. Typing indicators - Stop after 3s of inactivity
  4. Read receipts - Use Intersection Observer
  5. Offline support - Queue messages
  6. File uploads - Show progress
  7. Test edge cases - Reconnections, duplicates

Real-time chat is complex. Start simple, add features incrementally.

On this page