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.
Keep Reading
READY TO SHIP?
Build your MVP faster
Join founders using Greta to launch products in weeks, not months. Get a working prototype to test with real users — fast.
TALK TO A FOUNDER
Not sure where to start?
Book a 20-minute call. We'll map out your scope, tech stack, and go-to-market plan — for free.