Real-time chat looks deceptively simple. In production, you need: message delivery guarantees, offline message queuing, read receipts, typing indicators, message history pagination, and a system that handles network interruptions gracefully. Here's the full picture.
Transport Layer: WebSocket vs SSE vs Polling
WebSocket: Persistent bidirectional connection. Server can push to client anytime. Best for chat.
Server-Sent Events (SSE): Server-to-client only. Simpler than WebSocket, works over HTTP/2. Good for notifications, not full chat.
Long Polling: HTTP request held open until server has data. Legacy approach. Works everywhere but inefficient.
For chat: WebSocket is the answer. Bidirectional, low overhead, wide browser/mobile support.
Core Architecture
Client App ←→ WebSocket Server ←→ Message Queue ←→ Database
↓
Presence Service
↓
Push Notification Service
Why a message queue?
Direct DB writes on every message create write bottlenecks. Queue absorbs spikes, guarantees delivery order, enables fan-out (broadcasting to all room members).
WebSocket Server with Node.js
import { WebSocketServer } from 'ws';
import { createClient } from 'redis';
const wss = new WebSocketServer({ port: 8080 });
const clients = new Map(); // userId → WebSocket
wss.on('connection', (ws, req) => {
const userId = authenticateConnection(req); // JWT from query param or header
clients.set(userId, ws);
ws.on('message', async (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'send_message':
await handleSendMessage(message, userId);
break;
case 'typing_start':
broadcastToRoom(message.roomId, { type: 'typing', userId }, userId);
break;
case 'mark_read':
await markMessagesRead(message.roomId, userId, message.upToTimestamp);
break;
}
});
ws.on('close', () => {
clients.delete(userId);
updatePresence(userId, 'offline');
});
// Send undelivered messages
sendQueuedMessages(userId, ws);
});
Message Data Model
-- Conversations (direct or group)
conversations (
id UUID PRIMARY KEY,
type ENUM('direct', 'group'),
name TEXT, -- null for direct
created_at TIMESTAMPTZ
)
-- Conversation members
conversation_members (
conversation_id UUID REFERENCES conversations(id),
user_id UUID REFERENCES users(id),
joined_at TIMESTAMPTZ,
last_read_at TIMESTAMPTZ, -- for read receipts
PRIMARY KEY (conversation_id, user_id)
)
-- Messages
messages (
id UUID PRIMARY KEY,
conversation_id UUID REFERENCES conversations(id),
sender_id UUID REFERENCES users(id),
content TEXT,
type ENUM('text', 'image', 'file', 'system'),
reply_to_id UUID REFERENCES messages(id),
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
-- Index for pagination
INDEX (conversation_id, created_at DESC)
)
Message Delivery Guarantees
The hard part: what happens when the client disconnects mid-send?
Client-side approach:
- Client assigns message a local UUID before sending
- Client stores message locally as "pending"
- Server acknowledges with
{ ack: messageLocalId, serverMessageId, timestamp } - Client marks message as "delivered" on ack
If no ack within 5 seconds → retry. Server deduplicates by sender_local_id.
// Client
function sendMessage(content) {
const localId = crypto.randomUUID();
pendingMessages.set(localId, content);
ws.send(JSON.stringify({
type: 'send_message',
localId,
content,
conversationId
}));
setTimeout(() => {
if (pendingMessages.has(localId)) retry(localId);
}, 5000);
}
// Server acknowledges
ws.send(JSON.stringify({ type: 'ack', localId, serverId, timestamp }));
Read Receipts
Update last_read_at in conversation_members when user scrolls or opens conversation:
// Client sends when messages are visible
ws.send(JSON.stringify({
type: 'mark_read',
conversationId,
upToTimestamp: lastVisibleMessage.createdAt
}));
// Server
async function markMessagesRead(conversationId, userId, timestamp) {
await db.query(
`UPDATE conversation_members
SET last_read_at = $1
WHERE conversation_id = $2 AND user_id = $3`,
[timestamp, conversationId, userId]
);
// Broadcast read receipt to other members
broadcastToRoom(conversationId, {
type: 'read_receipt',
userId,
timestamp
}, userId);
}
Presence (Online/Offline/Typing)
Use Redis for presence - it's fast and ephemeral:
// User connects
await redis.set(`presence:${userId}`, 'online', 'EX', 30); // 30s TTL
// Heartbeat every 20 seconds from client
ws.on('ping', () => redis.expire(`presence:${userId}`, 30));
// Check if user is online
const isOnline = await redis.exists(`presence:${userId}`);
// Typing indicator
await redis.set(`typing:${conversationId}:${userId}`, '1', 'EX', 5);
Scaling WebSocket Servers
Single WebSocket server handles ~10,000-50,000 concurrent connections. Beyond that:
Problem: User A is on server 1, User B is on server 2. A sends to B - server 1 doesn't know B's connection.
Solution: Redis Pub/Sub
// Server 1 (User A's connection)
await redis.publish(`user:${recipientId}`, JSON.stringify(message));
// Server 2 (User B's connection)
const sub = createClient();
await sub.subscribe(`user:${userId}`, (message) => {
ws.send(message); // Forward to User B's WebSocket
});
Each server subscribes to channels for its connected users. Redis routes messages between servers.
Push Notifications for Offline Users
When recipient is offline, send push notification:
async function handleSendMessage(message, senderId) {
await saveMessage(message);
const recipients = await getConversationMembers(message.conversationId);
for (const recipientId of recipients) {
if (clients.has(recipientId)) {
// Online: real-time WebSocket
clients.get(recipientId).send(JSON.stringify(message));
} else {
// Offline: push notification
await sendPushNotification(recipientId, {
title: `New message from ${senderName}`,
body: message.content.substring(0, 100),
data: { conversationId: message.conversationId }
});
}
}
}
Message Pagination
Never load all messages at once. Cursor-based pagination:
-- Load messages before a cursor (infinite scroll upward)
SELECT * FROM messages
WHERE conversation_id = $1
AND created_at < $2 -- cursor
ORDER BY created_at DESC
LIMIT 50;
Load 50 messages initially. As user scrolls up, fetch older messages with the oldest message timestamp as cursor.
Build your chat application with us →
Aunimeda develops websites and web applications for businesses - corporate sites, e-commerce, portals, and custom platforms.
Contact us to discuss your web project. See also: Web Development, E-commerce Development