Skip to content
Greta.Agency
Case Studies

How Real-Time Chat Apps Actually Work

WebSockets, message queues, presence indicators, delivery receipts — real-time chat looks simple from the outside. Here's the architecture behind it, simplified.

MichaelApril 7, 20264 min read

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:

  1. Browser establishes a WebSocket connection to the server
  2. Server assigns the user to a "room" (conversation) based on their active channel
  3. 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_at updated 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.

M

Written by

Michael

Lead Engineer, Greta Agency

Michael has built real-time collaboration features across 15+ products including chat, live cursors, and presence systems.