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:
- Real-time communication understanding (WebSockets, polling)
- Performance optimization (virtualization, caching)
- State management for complex chat data
- 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.