WritingHow to Build a WebSocket Presence System in Node.js — Clixo
5 min readwebsockets, nodejs, real-time, presence, redis

How to Build a WebSocket Presence System in Node.js

Learn how to build a reliable WebSocket presence system in Node.js—track online users, handle heartbeats, and scale across instances with Redis.

You added real-time features to your app, but now users see stale "online" indicators long after someone has closed their tab. Your server has no reliable way to tell the difference between a live connection and a ghost. A proper WebSocket presence system solves this, and building one requires more than just tracking open sockets.

This guide walks through the full approach: connection tracking, heartbeat detection, multi-tab handling, and cross-instance synchronization with Redis.

What a WebSocket Presence System Actually Does

Presence is not just "is the socket open." It answers a more useful question: is this specific user actively connected right now, from any of their devices or tabs?

A complete system has four responsibilities:

  1. Identity mapping — associate each socket connection with an authenticated user ID
  2. Heartbeat tracking — detect connections that dropped without a clean close event
  3. Multi-tab support — keep a user online until all their connections close, not just one
  4. Broadcast — notify the right subscribers when someone's status changes

Step 1: Map Connections to User IDs

When a client connects, the first message it should send after the WebSocket handshake is an auth token. Verify that token server-side before treating the connection as valid.

const connections = new Map(); // userId -> Set of socket objects
 
wss.on('connection', (socket) => {
  socket.on('message', (raw) => {
    const msg = JSON.parse(raw);
 
    if (msg.type === 'auth') {
      const userId = verifyToken(msg.token);
      if (!userId) return socket.close(4001, 'Unauthorized');
 
      socket.userId = userId;
 
      if (!connections.has(userId)) {
        connections.set(userId, new Set());
      }
      connections.get(userId).add(socket);
 
      broadcastPresence(userId, 'online');
    }
  });
});

Store a Set per user, not a single socket reference. One user might have three browser tabs open.

Step 2: Detect Dead Connections with Heartbeats

The TCP layer does not always surface a closed connection immediately. A user on a mobile network can go offline and their socket stays "open" on the server for minutes. Fix this with a ping/pong heartbeat.

const HEARTBEAT_INTERVAL = 15000; // 15 seconds
const HEARTBEAT_TIMEOUT  = 35000; // miss 2 beats = dead
 
wss.on('connection', (socket) => {
  socket.isAlive = true;
  socket.on('pong', () => { socket.isAlive = true; });
});
 
setInterval(() => {
  wss.clients.forEach((socket) => {
    if (!socket.isAlive) return socket.terminate();
    socket.isAlive = false;
    socket.ping();
  });
}, HEARTBEAT_INTERVAL);

When isAlive is still false on the next ping cycle, terminate the socket. This keeps your presence map accurate.

Step 3: Handle Disconnections Correctly

On socket close, remove it from the user's connection set. Only broadcast offline if that set becomes empty — meaning all their tabs are gone.

socket.on('close', () => {
  const { userId } = socket;
  if (!userId) return;
 
  const userSockets = connections.get(userId);
  if (userSockets) {
    userSockets.delete(socket);
    if (userSockets.size === 0) {
      connections.delete(userId);
      broadcastPresence(userId, 'offline');
    }
  }
});

This is the detail most tutorials skip. Without it, closing one tab incorrectly marks a user offline while their other tabs are still active.

Step 4: Scale WebSocket Presence with Redis

The Multi-Server Problem

In-memory maps work on a single server. When you run two or more instances behind a load balancer, server A has no idea who is connected to server B. A user on server A will never see presence events for someone on server B.

Using Redis Pub/Sub for Presence Events

Publish every presence change to a shared Redis channel. Each server subscribes and forwards the event to its local connected clients.

// On presence change
redisPublisher.publish('presence', JSON.stringify({ userId, status }));
 
// On each server instance
redisSubscriber.subscribe('presence');
redisSubscriber.on('message', (channel, raw) => {
  const event = JSON.parse(raw);
  broadcastToLocalClients(event);
});

Using Redis for Shared State

Store the count of active connections per user in Redis so any server can answer "is this user online" without inspecting local memory:

HINCRBY presence:counts <userId> 1   // on connect
HINCRBY presence:counts <userId> -1  // on disconnect

When the count hits zero, the user is offline globally. Set a short TTL as a safety net against crashed servers that never decremented the counter.

Common Pitfalls

  • Skipping token verification on connect — never trust a socket until the identity is confirmed
  • Broadcasting presence to everyone — scope presence events to rooms or channels; global fans out poorly at scale
  • Not handling server crashes — if a server process dies, its clients disconnect but Redis counters may not be decremented; a TTL or a startup reconciliation job handles this
  • Polling for presence from the client — let the server push presence changes; polling defeats the purpose of WebSockets

What This Enables

Once you have reliable presence, you unlock a set of features that users notice: "3 people are viewing this document," cursor overlays in collaborative tools, typing indicators in chat, and accurate "last seen" timestamps. These details significantly raise the perceived quality of a real-time product.

Building a presence system correctly on the first pass takes disciplined architecture. If you are designing a collaborative product or adding real-time features to an existing app, talk to Clixo. We design and ship real-time systems from the WebSocket layer through the database.