Learn the concept
Real-Time System Architecture
A real-time chat application uses WebSocket connections with heartbeat and exponential backoff reconnection for message delivery, optimistic UI for instant message display, virtualized message lists for performance, and features like typing indicators (debounced broadcasts), read receipts (intersection observer), and file uploads (presigned URLs with progress tracking).
Designing a chat application is one of the most comprehensive frontend system design questions because it touches nearly every aspect of modern web development: real-time communication, state management, performance optimization, accessibility, and complex UI interactions.
Functional:
Non-functional:
WebSocket is the correct transport for chat because it provides full-duplex, low-latency communication. The connection lifecycle requires careful management:
delay = min(baseDelay * 2^attempt + randomJitter, maxDelay). This prevents a thundering herd when a server restarts and thousands of clients reconnect simultaneously.channelId to route them to the correct conversation.Message {
id: string (UUID or server-generated snowflake)
channelId: string
senderId: string
content: string
type: 'text' | 'image' | 'file' | 'system'
threadId: string | null (for replies)
reactions: Map<emoji, userId[]>
createdAt: timestamp
updatedAt: timestamp | null
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
replyTo: string | null (quoted message ID)
}
Use client-generated UUIDs for message IDs. This enables optimistic UI — the message can be rendered immediately with a temporary ID that becomes permanent when the server confirms.
Chat state is complex. Organize it into domains:
Use normalized state (messages stored by ID in a map, channels reference message IDs) to avoid duplication and enable efficient updates when a message is edited or reacted to.
When the user presses Send:
status: 'sending'.status: 'sent'.status: 'failed' and show a retry button.This makes the app feel instant even on slow connections. The key is having a unique client ID so the server's confirmation can be matched to the optimistic message.
Chat conversations can contain hundreds of thousands of messages. Rendering them all would crash the browser. Use virtualization to render only the visible messages plus a small overscan buffer.
scrollTop.Use cursor-based pagination (not offset-based) for loading older messages:
GET /api/channels/{id}/messages?before={oldestMessageId}&limit=50Typing indicators require balancing responsiveness with network efficiency:
typing_start event to the channel.Use the Intersection Observer API to detect which messages are visible in the viewport:
Use presigned URLs for file uploads to avoid routing large files through your WebSocket or API server:
XMLHttpRequest or fetch with a ReadableStream.<canvas> before the upload completes for instant visual feedback.Implement a two-tier search strategy:
aria-live="polite") for new incoming messages so screen readers announce them.type MessageHandler = (data: any) => void;
interface ChatSocketOptions {
url: string;
token: string;
onMessage: MessageHandler;
onStatusChange: (status: 'connecting' | 'connected' | 'disconnected' | 'reconnecting') => void;
}
class ChatSocketManager {
private ws: WebSocket | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempt = 0;
private maxReconnectAttempt = 15;
private messageQueue: any[] = [];
private isIntentionallyClosed = false;
constructor(private options: ChatSocketOptions) {}
connect(): void {
this.isIntentionallyClosed = false;
this.options.onStatusChange('connecting');
this.ws = new WebSocket(`${this.options.url}?token=${this.options.token}`);
this.ws.onopen = () => {
this.options.onStatusChange('connected');
this.reconnectAttempt = 0;
this.startHeartbeat();
this.flushQueue();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Heartbeat response — reset the timeout
if (data.type === 'pong') return;
this.options.onMessage(data);
};
this.ws.onclose = (event) => {
this.stopHeartbeat();
this.options.onStatusChange('disconnected');
if (!this.isIntentionallyClosed && event.code !== 1000) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// onerror is always followed by onclose, so reconnection
// is handled there. Just log for debugging.
};
}
send(data: any): void {
const message = JSON.stringify(data);
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
// Queue for later delivery
this.messageQueue.push(data);
}
}
disconnect(): void {
this.isIntentionallyClosed = true;
this.stopHeartbeat();
clearTimeout(this.reconnectTimeout!);
this.ws?.close(1000, 'User disconnected');
}
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
// If no pong within 5 seconds, connection is dead
const pongTimeout = setTimeout(() => {
this.ws?.close(4000, 'Heartbeat timeout');
}, 5000);
// Clear the timeout when we receive any message
// (the onmessage handler above handles pong)
const originalHandler = this.ws.onmessage;
this.ws.onmessage = (event) => {
clearTimeout(pongTimeout);
this.ws!.onmessage = originalHandler;
originalHandler?.call(this.ws, event);
};
}
}, 30000);
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
// Exponential backoff with jitter to prevent thundering herd
private scheduleReconnect(): void {
if (this.reconnectAttempt >= this.maxReconnectAttempt) {
console.error('Max reconnection attempts reached');
return;
}
this.options.onStatusChange('reconnecting');
const baseDelay = 1000;
const maxDelay = 30000;
const exponentialDelay = baseDelay * Math.pow(2, this.reconnectAttempt);
const jitter = Math.random() * 1000;
const delay = Math.min(exponentialDelay + jitter, maxDelay);
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempt++;
this.connect();
}, delay);
}
// Replay queued messages after reconnection
private flushQueue(): void {
while (this.messageQueue.length > 0) {
const data = this.messageQueue.shift();
this.send(data);
}
}
}Building a Slack-like workspace with channels, direct messages, threads, and integrations, requiring real-time message delivery across thousands of concurrent users
Embedding a chat widget on a website where customers can communicate with support agents in real time, with typing indicators and file sharing for screenshots
Adding direct messaging to a social media application, with read receipts, online presence, and media sharing, handling millions of concurrent conversations
Build a multi-room chat application with WebSocket connections, optimistic message sending, typing indicators, and message history with cursor-based pagination
Create a virtualized message list that handles variable-height messages, maintains scroll position when loading history, and auto-scrolls on new messages
Uses WebSocket connections for real-time message delivery with sophisticated reconnection logic, message threading, and presence indicators across millions of workspaces
Handles millions of concurrent WebSocket connections with gateway sharding, optimistic UI for instant message display, and efficient binary encoding for reduced bandwidth
Uses WebSocket connections for real-time messaging with end-to-end encryption. Since 2021, multi-device support allows up to 4 linked devices to connect independently without requiring the phone to be online
Design a real-time notification system for a web application
Design an autocomplete/typeahead search component
How do you design a component library / design system for multiple teams?
What is the difference between client state and server state?