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:
- Identity mapping — associate each socket connection with an authenticated user ID
- Heartbeat tracking — detect connections that dropped without a clean close event
- Multi-tab support — keep a user online until all their connections close, not just one
- 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.