Warming up the neural circuits...
By the end of this chapter you will:
Real-time communication is table stakes for modern applications — support chat, live collaboration, multiplayer games, notification systems. This project strips WebSockets down to their engineering fundamentals: connection management, presence tracking, message persistence, and horizontal scaling. You'll build a chat backend that works with one server and scales to many, using Redis pub/sub to bridge messages across instances.
Client → Server messages:
{ type: "join", roomId: "room_123" }
{ type: "leave", roomId: "room_123" }
{ type: "message", roomId: "room_123", content: "Hello!" }
{ type: "typing:start", roomId: "room_123" }
{ type: "typing:stop", roomId: "room_123" }
Server → Client messages:
{ type: "message", id: "msg_456", roomId: "room_123", userId: "user_1",
username: "alice", content: "Hello!", createdAt: "..." }
{ type: "presence", roomId: "room_123", users: ["user_1", "user_2"] }
{ type: "typing", roomId: "room_123", userId: "user_2", isTyping: true }
{ type: "error", code: "ROOM_NOT_FOUND", message: "Room does not exist" }
{ type: "history", roomId: "room_123", messages: [...] }-- Users (shared from auth module)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Chat rooms
CREATE TABLE rooms (
id UUID PRIMARY KEY DEFAULT
Typing indicators and presence are ephemeral — Redis only, not persisted to PostgreSQL.
ADR-1: ws (not Socket.io) for server. Socket.io adds auto-reconnection, fallback transports, and rooms abstraction — valuable but obscures the fundamentals. Using the ws library forces you to understand the WebSocket protocol directly.
ADR-2: Redis pub/sub for cross-server broadcasting. When running multiple server instances, a message sent to Server A must reach clients connected to Server B. Redis pub/sub channels per room broadcast messages across all server instances.
ADR-3: PostgreSQL for message persistence, Redis for ephemeral . Messages are durable (PostgreSQL). Presence and typing indicators are ephemeral (Redis with TTL). This separation optimizes each store for its access pattern.
ADR-4: No authentication in WebSocket handshake headers — use query param token. The WebSocket upgrade request doesn't support custom headers in browsers. Pass the as a query parameter: ws://localhost:3000?token=.... Validate on connection.
chat-backend/
├── src/
│ ├── config/
│ │ └── index.ts # Env vars, Redis URL, DB pool, JWT secret
│ ├── db/
│ │ ├── migrations/
│ │ │ ├── 001_users.sql
│ │ │ ├── 002_rooms.sql
│ │ │ ├── 003_room_members.sql
│ │ │ └── 004_messages.sql
│ │ ├── pool.ts
│ │ └── migrate.ts
│ ├── ws/
│ │ ├── server.ts # WebSocket server setup + upgrade handling
│ │ ├── connection-manager.ts # Track connected clients per user, per room
│ │ ├── handlers/
│ │ │ ├── auth.ts # JWT validation on connect
│ │ │ ├── join.ts # Join room handler
│ │ │ ├── message.ts # Message handler (persist + broadcast)
│ │ │ ├── typing.ts # Typing indicator handler
│ │ │ └── presence.ts # Presence broadcast handler
│ │ └── types.ts # WebSocket message types
│ ├── redis/
│ │ ├── pubsub.ts # Redis publisher + subscriber
│ │ └── presence.ts # Presence tracking (Redis sets)
│ ├── rest/
│ │ ├── rooms.routes.ts # REST endpoints for room CRUD
│ │ └── messages.routes.ts # REST endpoint for message history
│ ├── middleware/
│ │ ├── auth.ts # JWT middleware (shared with WS)
│ │ └── error-handler.ts
│ ├── lib/
│ │ ├── errors.ts
│ │ └── logger.ts
│ └── app.ts # Express + WS server
ws, pg, ioredisPOST /api/rooms, GET /api/rooms, POST /api/rooms/:id/joinGET /api/rooms/:id/messages — cursor-paginated message historyws WebSocket server alongside ExpressConnectionManager: Map of userId → Set of WebSocket connectionsjoin handler: validate room membership, add to room, send historyleave handler: remove from room trackingmessage handler: persist to PG, broadcast to roomroom:{roomId}:presence with TTLping every 30s; server tracks last heartbeattyping:start/typing:stop broadcast via Redis pub/sub (not persisted)room:{roomId} channelThe connection manager tracks which users are connected and which rooms they're in:
// ws/connection-manager.ts
import WebSocket from 'ws';
interface ClientInfo {
userId: string;
username: string;
ws: WebSocket;
rooms: Set<string>;
// ws/server.ts
import { WebSocketServer, WebSocket } from 'ws';
import { Server } from 'http';
import jwt from 'jsonwebtoken';
import { connectionManager } from './connection-manager'
// redis/pubsub.ts
import Redis from 'ioredis';
import { connectionManager } from '../ws/connection-manager';
const publisher = new Redis({ host: 'localhost', port: 6379 });
const
// ws/handlers/message.ts
import { db } from '../../db/pool';
import { broadcastMessage } from '../../redis/pubsub';
export async function handleMessage(
userId: string,
username: string,
roomId
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Not tracking which rooms a connection is in | On disconnect, you can't broadcast presence updates because you don't know which rooms the user was in | Store a Set<roomId> per connection. On close, iterate and broadcast user_left to each room |
| Broadcasting to the sender | The sender sees their own message twice — once optimistically (UI) and once from the broadcast | Exclude the sender's userId from the broadcast (excludeUserId parameter) |
| Not persisting messages to a database | Server restart = all in-memory messages lost. Users lose conversation history | Persist every message to PostgreSQL at write time. WebSocket is for real-time delivery; PostgreSQL is for durability |
| Using a single Redis channel for all messages | All servers receive all messages for all rooms, filtering in application code — O(n) overhead per message | Use per-room Redis channels (room:{roomId}) or a single channel with server-side filtering |
| Not implementing heartbeats | Stale connections (zombie clients, network partitions) accumulate. Presence shows users as online indefinitely | Ping every 30s. If no pong within 10s, terminate connection and clean up presence |
| Assuming a single server instance | Everything works in development; breaks in production with a | Design for horizontal scaling from day one: Redis pub/sub for cross-server broadcast |
| Not handling reconnection on the client side | Temporary network blips disconnect users permanently | Implement exponential backoff reconnection on the client: 1s → 2s → 4s → 8s → max 30s |
Q: Why use Redis pub/sub instead of having each server track all connected clients? A: Each server only knows about clients connected to it. If Server A has Client 1 and Server B has Client 2 in the same room, Server A can't directly send a message to Client 2. Redis pub/sub bridges this gap: Server A publishes to Redis, Server B (subscribed) receives and broadcasts to Client 2. This is the standard pattern for horizontally scaling WebSocket servers.
Q: Why persist messages to PostgreSQL instead of keeping them in Redis? A: Redis is primarily an in-memory store. While it can persist to disk (RDB/AOF), it's not designed as a durable message store. Redis restarts can lose data. PostgreSQL provides ACID durability, indexing for efficient pagination of message history, and is the system you already have operational expertise for. Use Redis for what it's good at (ephemeral pub/sub, presence); use PostgreSQL for what it's good at (durable storage, querying).
Q: How do you handle the case where a message is broadcast but the recipient is offline?
A: The message is persisted to PostgreSQL regardless. When the recipient reconnects and joins the room, they receive a history event with recent messages (last 50). The real-time broadcast is best-effort delivery; PostgreSQL is the source of truth. This is the same pattern Slack uses: messages are always in the database; the WebSocket is for live delivery.
This project builds a production-grade chat backend with WebSockets, PostgreSQL, and Redis. The connection manager tracks clients and rooms. Messages are persisted to PostgreSQL for durability and broadcast via WebSocket for real-time delivery. Redis pub/sub enables horizontal scaling across multiple server instances. Presence tracking uses Redis Sets with TTLs; typing indicators use ephemeral pub/sub messages. The architecture separates concerns cleanly: PostgreSQL for durable state, Redis for ephemeral coordination, WebSockets for real-time transport.
ws library (not Socket.io) for understanding the raw WebSocket protocol.room:{roomId}:presence with heartbeat-based TTL.