The Problem
HTTP is request-response: the client asks, the server answers, the connection closes. For a chat app, this model is wrong. You need the server to push messages to connected clients the moment they're sent — without each client polling every second.
The solution is persistent connections that allow bidirectional communication.
Key Components
1. WebSocket Server
A WebSocket is a persistent TCP connection between a client and a server. Unlike HTTP, both sides can send messages at any time without a new request.
When a user opens your chat app:
- —Browser establishes a WebSocket connection to the server
- —Server assigns the user to a "room" (conversation) based on their active channel
- —When any user sends a message, the server broadcasts it to all other connections in that room
// Server (Node.js + ws library)
wss.on('connection', (ws, req) => {
const userId = authenticateUser(req);
const roomId = getRoomFromUrl(req.url);
rooms.get(roomId).add(ws);
ws.on('message', (data) => {
const message = JSON.parse(data);
saveToDatabase(message);
broadcastToRoom(roomId, message, ws); // exclude sender
});
ws.on('close', () => {
rooms.get(roomId).delete(ws);
});
});
2. Database (Messages)
Messages are stored in a relational database:
messages (
id UUID PRIMARY KEY,
room_id UUID,
sender_id UUID,
content TEXT,
created_at TIMESTAMP,
edited_at TIMESTAMP,
deleted_at TIMESTAMP -- soft delete
)
The soft delete pattern (deleted_at instead of deleting the row) preserves message history and prevents gaps in conversation threads.
3. Message Queue (Fan-out at Scale)
A single WebSocket server can handle ~10,000 concurrent connections. At Slack's scale (millions of concurrent users), messages need to fan out across hundreds of servers.
Redis Pub/Sub handles this: each server subscribes to the rooms for its connected users. When any server receives a message, it publishes to Redis, and Redis delivers it to all subscribing servers — which then push to their connected clients.
// Publisher (when message is received)
redis.publish(`room:${roomId}`, JSON.stringify(message));
// Subscriber (on each server startup)
redis.subscribe(`room:${roomId}`, (message) => {
broadcastToLocalConnections(roomId, message);
});
4. Presence System
Presence ("Sarah is online") requires tracking which users are connected across all servers. The data structure:
- —On connect:
SET user:{userId}:status online EX 60(Redis key with 60-second TTL) - —Every 30 seconds: client sends a heartbeat, server refreshes the TTL
- —On disconnect: key expires, user is "offline"
- —Other clients check presence via Redis key lookup or subscribe to presence change events
5. Delivery Receipts
Delivery receipts (sent → delivered → read) require:
message_receipts (
message_id UUID,
user_id UUID,
delivered_at TIMESTAMP,
read_at TIMESTAMP
)
- —Sent: message saved to database
- —Delivered: record created when the receiving client's WebSocket receives the message
- —Read:
read_atupdated when the client's viewport contains the message (IntersectionObserver)
Why It Scales
Horizontal scaling: WebSocket servers are stateless except for the in-memory connection map. Adding more servers increases capacity linearly. Redis Pub/Sub handles cross-server message delivery.
Database sharding: At scale, messages can be sharded by room_id — all messages for a given conversation live on the same database shard, which optimizes read performance for conversation history queries.
CDN for static assets, WebSockets for real-time: The app shell and static assets are CDN-cached. Only the real-time message stream goes through WebSockets. This minimizes the load on the real-time infrastructure.
How You Can Build This
For most products, you don't need to build this from scratch:
Supabase Realtime: Built on top of PostgreSQL and WebSockets. You subscribe to table changes, and Supabase pushes updates to all subscribers. Handles presence, broadcast, and database change listening.
// Subscribe to new messages in a room
supabase
.channel(`room:${roomId}`)
.on('postgres_changes', { event: 'INSERT', table: 'messages' }, handleNewMessage)
.on('presence', { event: 'sync' }, handlePresenceSync)
.subscribe();
Estimated complexity:
- —Basic chat with WebSockets + Supabase: 3–5 days
- —With presence, delivery receipts, message reactions: 10–14 days
- —Self-hosted at scale with Redis + horizontal WebSocket servers: 4–6 weeks
Tech stack suggestion: Next.js + Supabase Realtime for most products. Custom WebSocket server (Node.js ws + Redis) for products that need more control or are already at significant scale.
Written by
Michael
Lead Engineer, Greta Agency
Michael has built real-time collaboration features across 15+ products including chat, live cursors, and presence systems.