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

How to Build a WebSocket Presence System in Node.js

Build WebSocket presence tracking in Node.js with connection sets, heartbeats, multi-tab handling, and shared-state recovery considerations.

Presence tracking estimates whether a user has an active connection across their tabs and devices. It needs authenticated connection records, heartbeat handling, and a way to reconcile state across server instances.

What Presence Tracking 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 bounds detection time according to the heartbeat interval and timeout.

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');
    }
  }
});

Removing only the closed connection keeps other tabs represented in the local set.

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

The simple counter above illustrates shared state, but does not safely reconcile crashes by itself. A TTL on the entire user count can expire while another server still has a live connection. Track process-owned connections and reconcile counts when a process disappears; see the Socket.IO cluster counting example.

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; track process ownership and test crash cleanup without removing another server’s live connections
  • Polling for presence from the client — let the server push presence changes; polling defeats the purpose of WebSockets

What This Enables

Presence can support document-viewer counts and connection status. Treat status as time-bounded information: network loss and heartbeat intervals create a delay before a user is marked offline.

Discuss presence tracking with LinkIT.