# 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.

- **Published:** 2025-04-01
- **Author:** Clixo
- **Reading time:** 5 min read
- **Tags:** websockets, nodejs, real-time, presence, redis
- **Canonical URL:** https://clixo.sh/blog/websocket-presence-system-nodejs

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:

```mermaid
flowchart TD
  CONN[Client connects] --> AUTH[Verify auth token]
  AUTH -->|invalid| CLOSE["Close 4001 Unauthorized"]
  AUTH -->|valid| MAP[Add socket to user connection set]
  MAP --> PING[Server pings every 15s]
  PING -->|pong received| PING
  PING -->|no pong| TERM[Terminate dead socket]
  TERM --> DISC[Remove from set]
  DISC -->|set empty| PUB["Broadcast offline via Redis"]
  DISC -->|set not empty| ALIVE[User still online]
```

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.

```js
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.

```js
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.

```js
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.

```js
// 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](https://clixo.sh/#contact). We design and ship real-time systems from the WebSocket layer through the database.

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
