Skip to content

TypeScript and Socket.IO messaging platform

Real-Time Chat: Presence, Fan-Out and Backpressure

Building a real-time messaging platform on Node.js and Socket.IO, and the state-ownership problems that surface when you scale past one process.

Transport
WebSocket + Redis adapter
Presence
TTL-based leases
Auth
Handshake middleware
Status
Live demo available

Role

Full-stack design and implementation

Timeline

2026

Stack

Node.js, TypeScript, Socket.IO, Redis, MongoDB

Overview

A real-time messaging platform built on Node.js, TypeScript and Socket.IO, with room-based conversations, presence indicators and message history. The client is a React application; the server is a typed Express and Socket.IO process backed by MongoDB for persistence and Redis for coordination.

I wrote about the general lessons from this build in more depth in my article on scaling WebSockets. This case study covers the specific decisions in this codebase.

The problem

Chat is deceptively easy to prototype. Socket.IO's own tutorial gets you a working room in about thirty lines. The difficulty is that almost every one of those thirty lines assumes a single process holding all connections in memory — an assumption that fails the moment you want redundancy, let alone scale.

Architecture

The design deliberately keeps no authoritative state in process memory:

  • Message fan-out goes through the Redis adapter, so any server can deliver to a client connected to any other server.
  • Presence lives in Redis as a set of socket IDs per user, with a TTL refreshed by heartbeat.
  • Message history is persisted to MongoDB and paginated on join, so a reconnecting client recovers what it missed.
  • Authentication happens in handshake middleware, before the connection is registered.
ts
io.use(async (socket, next) => {
  const token = socket.handshake.auth?.token;
  if (!token) return next(new Error("unauthorized"));

  try {
    const claims = await verifyJwt(token);
    socket.data.userId = claims.sub;
    next();
  } catch {
    next(new Error("unauthorized"));
  }
});

Technical challenges

Presence that survives a crashed process

The first presence implementation was a boolean set on connect and cleared on disconnect. It drifted within days, because a process that is killed never runs its disconnect handler and leaves users permanently 'online'.

Modelling presence as an expiring lease fixed it structurally rather than defensively. A user is online because a socket recently asserted it; if the asserting process dies, the key expires on its own. Storing a set of socket IDs rather than a flag also handles the multi-device case, where closing one tab should not mark someone offline on their phone.

Ordering under concurrent sends

Two messages sent milliseconds apart from different clients can arrive at different servers and be persisted out of order, so clients disagree about the sequence. Wall-clock timestamps do not resolve it — server clocks drift.

Ordering is established at persistence time by a monotonic per-room sequence, and clients sort on that rather than on timestamps. The message a client renders optimistically is reconciled against the authoritative sequence when the server acknowledges it.

Not dying under slow clients

Writes to a socket whose peer has stopped reading queue in the server's send buffer. Enough of those and heap grows, garbage collection pauses stretch, and healthy clients start seeing latency caused entirely by unhealthy ones.

Ephemeral events — typing indicators, presence changes — are dropped rather than queued when a socket's buffer exceeds a threshold. Losing a typing indicator is invisible; losing the server is not. Durable messages are not subject to this, because they are recoverable from history on reconnect.

Outcome

The result is a messaging layer that behaves correctly across multiple processes, recovers cleanly from server restarts, and degrades gracefully rather than catastrophically when individual clients misbehave.

The transferable lesson was recognising which state I had assumed was global. Room membership, presence and rate-limit counters all looked like application logic and were actually infrastructure concerns.

Published by Cenedy Udoy Palma.

Other case studies