PatternsReal-Time Architecture
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
- Optimistic updates - Add messages immediately
- Message ordering - Use sequence numbers
- Typing indicators - Stop after 3s of inactivity
- Read receipts - Use Intersection Observer
- Offline support - Queue messages
- File uploads - Show progress
- Test edge cases - Reconnections, duplicates
Real-time chat is complex. Start simple, add features incrementally.