All posts
10 Sep 2026

Building Real-Time Presence and Cursor Tracking in Node.js with WebSockets

A practical architectural guide on building a high-performance, low-latency collaborative cursor and user presence engine using Node.js and WebSockets.

Building Real-Time Presence and Cursor Tracking in Node.js with WebSockets

Collaborative features like those found in Figma, Google Docs, and Miro have shifted from “nice-to-have” novelties to core product expectations. When multiple users work on the same canvas or document simultaneously, seeing where others are looking and moving their cursors creates an invaluable sense of shared space.

However, building a real-time cursor and presence engine introduces unique engineering challenges. Unthrottled mouse movements can easily flood a backend server with hundreds of messages per second per client, leading to network congestion, garbage collection pauses, and skyrocketing server costs.

In this architectural guide, we will walk through building a production-ready, low-latency real-time presence and cursor tracking engine using Node.js, WebSockets (via ws), and client-side throttling strategies.


System Architecture Overview

To build a scalable collaborative environment, our system needs to solve three primary problems:

  1. Bidirectional Low-Latency Transport: HTTP polling is too slow and chatty. We need persistent, full-duplex TCP connections via WebSockets.
  2. Message Throttling and Batching: Mousemove events fire rapidly. We must throttle these events on the client to preserve network bandwidth.
  3. Ephemeral State Management: Presence and cursor coordinates do not need to be written to a persistent database on every tick. They live in fast, in-memory Node.js data structures and expire if a client disconnects.

Architectural Flow

text
+----------+     Throttled (50ms)     +-------------------+     Broadcast     +----------+
| Client A | -----------------------> |                   | ----------------> | Client B |
+----------+                          |   Node.js Server  |                   +----------+
                                      |  (WebSocket Hub)  |                   +----------+
+----------+                          |                   | ----------------> | Client C |
| Client Z | -----------------------> |                   |                   +----------+
+----------+                          +-------------------+                    

Step 1: Setting Up the Node.js WebSocket Server

We will use ws, the fastest and most memory-efficient WebSocket library for Node.js. First, initialize your project and install dependencies:

mkdir collab-engine && cd collab-engine
npm init -y
npm install ws uuid
npm install -D nodemon

Now, let’s build the core server structure. We need a server that handles connection lifecycles, assigns unique IDs, manages active rooms, and broadcasts state changes efficiently.

server.js

const { WebSocketServer } = require('ws');
const { v4: uuidv4 } = require('uuid');

const wss = new WebSocketServer({ port: 8080 });

// In-memory store for rooms and clients
// Structure: rooms.get(roomId) -> Map(clientId -> { ws, metadata })
const rooms = new Map();

wss.on('connection', (ws, req) => {
  const clientId = uuidv4();
  let currentRoom = null;

  console.log(`[Connection] Client connected: ${clientId}`);

  ws.on('message', (rawMessage) => {
    let message;
    try {
      message = JSON.parse(rawMessage);
    } catch (err) {
      console.error('Invalid JSON received:', rawMessage);
      return;
    }

    switch (message.type) {
      case 'JOIN_ROOM': {
        const { roomId, username, color } = message.payload;
        
        // Leave previous room if any
        if (currentRoom) {
          leaveRoom(clientId, currentRoom, ws);
        }

        currentRoom = roomId;
        if (!rooms.has(roomId)) {
          rooms.set(roomId, new Map());
        }

        const roomClients = rooms.get(roomId);
        
        // Store client metadata
        roomClients.set(clientId, {
          ws,
          profile: {
            id: clientId,
            username: username || `User-${clientId.slice(0, 4)}`,
            color: color || '#3b82f6',
            cursor: { x: 0, y: 0 },
            viewport: { x: 0, y: 0, zoom: 1 }
          }
        });

        // Notify existing clients about the new user
        broadcastPresence(roomId, clientId);

        // Send current room state to the newly joined client
        sendInitialState(roomId, clientId, ws);
        break;
      }

      case 'CURSOR_MOVE': {
        if (!currentRoom) return;
        const roomClients = rooms.get(currentRoom);
        if (!roomClients) return;

        const client = roomClients.get(clientId);
        if (client) {
          client.profile.cursor = message.payload.cursor;
          
          // Broadcast cursor position to all OTHER clients in the room
          broadcastToRoom(currentRoom, clientId, {
            type: 'CURSOR_UPDATE',
            payload: {
              clientId,
              cursor: message.payload.cursor
            }
          });
        }
        break;
      }

      case 'VIEWPORT_UPDATE': {
        if (!currentRoom) return;
        const roomClients = rooms.get(currentRoom);
        if (!roomClients) return;

        const client = roomClients.get(clientId);
        if (client) {
          client.profile.viewport = message.payload.viewport;

          broadcastToRoom(currentRoom, clientId, {
            type: 'VIEWPORT_UPDATE',
            payload: {
              clientId,
              viewport: message.payload.viewport
            }
          });
        }
        break;
      }

      default:
        console.log(`Unknown message type: ${message.type}`);
    }
  });

  ws.on('close', () => {
    console.log(`[Disconnection] Client disconnected: ${clientId}`);
    if (currentRoom) {
      leaveRoom(clientId, currentRoom, ws);
    }
  });
});

function leaveRoom(clientId, roomId, ws) {
  const roomClients = rooms.get(roomId);
  if (!roomClients) return;

  roomClients.delete(clientId);

  // Notify remaining clients
  broadcastToRoom(roomId, clientId, {
    type: 'USER_LEFT',
    payload: { clientId }
  });

  // Cleanup empty rooms
  if (roomClients.size === 0) {
    rooms.delete(roomId);
    console.log(`[Room Cleanup] Room ${roomId} deleted because it's empty.`);
  }
}

function broadcastToRoom(roomId, senderId, data) {
  const roomClients = rooms.get(roomId);
  if (!roomClients) return;

  const payloadString = JSON.stringify(data);

  for (const [id, client] of roomClients.entries()) {
    if (id !== senderId && client.ws.readyState === 1) {
      client.ws.send(payloadString);
    }
  }
}

function broadcastPresence(roomId, newClientId) {
  const roomClients = rooms.get(roomId);
  if (!roomClients) return;

  const newClient = roomClients.get(newClientId);

  // Broadcast to everyone else that a new user joined
  broadcastToRoom(roomId, newClientId, {
    type: 'USER_JOINED',
    payload: { profile: newClient.profile }
  });
}

function sendInitialState(roomId, clientId, ws) {
  const roomClients = rooms.get(roomId);
  if (!roomClients) return;

  const peers = [];
  for (const [id, client] of roomClients.entries()) {
    if (id !== clientId) {
      peers.push(client.profile);
    }
  }

  ws.send(JSON.stringify({
    type: 'ROOM_STATE',
    payload: { peers }
  }));
}

console.log('WebSocket presence server running on ws://localhost:8080');

Step 2: Client-Side Throttling and Transmission

A common beginner mistake is attaching a raw mousemove listener directly to send WebSocket messages:

// ANTI-PATTERN: Do NOT do this
window.addEventListener('mousemove', (e) => {
  socket.send(JSON.stringify({ type: 'CURSOR_MOVE', payload: { x: e.clientX, y: e.clientY } }));
});

At 60Hz or 120Hz monitor refresh rates, this fires up to 120 messages per second per user. With 50 concurrent users in a room, your server would handle 6,000 messages per second solely for cursor movements—destroying network performance.

Implementing RequestAnimationFrame Throttling

To solve this, we throttle outgoing cursor updates using requestAnimationFrame or a time-bucket interval (e.g., every 50ms). This ensures we send at most 20 updates per second, which looks silky-smooth to the human eye while reducing network load by 80%.

Here is a robust client-side implementation pattern:

class PresenceClient {
  constructor(url, roomId, userProfile) {
    this.url = url;
    this.roomId = roomId;
    this.userProfile = userProfile;
    this.ws = null;
    this.peers = new Map();
    
    this.lastSentCursor = { x: 0, y: 0 };
    this.pendingCursor = null;
    this.isThrottleScheduled = false;

    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log('Connected to presence server');
      this.send({
        type: 'JOIN_ROOM',
        payload: {
          roomId: this.roomId,
          username: this.userProfile.username,
          color: this.userProfile.color
        }
      });
      this.initListeners();
    };

    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleMessage(message);
    };

    this.ws.onclose = () => {
      console.log('Disconnected. Reconnecting in 2s...');
      setTimeout(() => this.connect(), 2000);
    };
  }

  initListeners() {
    window.addEventListener('mousemove', (e) => {
      this.pendingCursor = { x: e.clientX, y: e.clientY };
      this.scheduleCursorDispatch();
    });
  }

  scheduleCursorDispatch() {
    if (this.isThrottleScheduled) return;

    this.isThrottleScheduled = true;
    
    // Throttle using requestAnimationFrame or a 50ms timeout
    setTimeout(() => {
      if (this.pendingCursor) {
        this.send({
          type: 'CURSOR_MOVE',
          payload: { cursor: this.pendingCursor }
        });
        this.lastSentCursor = this.pendingCursor;
      }
      this.isThrottleScheduled = false;
    }, 50); // ~20 updates per second
  }

  send(data) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }

  handleMessage(message) {
    switch (message.type) {
      case 'ROOM_STATE':
        message.payload.peers.forEach(peer => {
          this.peers.set(peer.id, peer);
          this.renderPeer(peer);
        });
        break;

      case 'USER_JOINED':
        this.peers.set(message.payload.profile.id, message.payload.profile);
        this.renderPeer(message.payload.profile);
        break;

      case 'CURSOR_UPDATE':
        const peer = this.peers.get(message.payload.clientId);
        if (peer) {
          peer.cursor = message.payload.cursor;
          this.updatePeerCursor(peer);
        }
        break;

      case 'USER_LEFT':
        this.peers.delete(message.payload.clientId);
        this.removePeerElement(message.payload.clientId);
        break;
    }
  }

  renderPeer(peer) {
    // DOM creation logic for remote user cursors
    let el = document.getElementById(`cursor-${peer.id}`);
    if (!el) {
      el = document.createElement('div');
      el.id = `cursor-${peer.id}`;
      el.className = 'absolute pointer-events-none transition-transform duration-75 ease-out z-50';
      el.innerHTML = `
        <svg width="24" height="24" viewBox="0 0 24 24" fill="${peer.color}" xmlns="http://www.w3.org/2000/svg">
          <path d="M5.65376 12.3673H5.65402L15.5413 2.48004C16.0357 1.98565 16.8997 2.33391 16.9248 3.03157L17.7011 24.5262C17.724 25.1614 16.9602 25.539 16.4485 25.1118L11.5946 21.0543L8.13524 24.3168C7.6974 24.7335 7.00973 24.3161 7.12745 23.7153L8.53696 16.5684L5.1979 14.1729C4.65215 13.7842 4.79361 12.9231 5.65376 12.3673Z" />
        </svg>
        <span class="ml-4 px-2 py-0.5 text-xs text-white rounded shadow" style="background-color: ${peer.color}">${peer.username}</span>
      `;
      document.body.appendChild(el);
    }
  }

  updatePeerCursor(peer) {
    const el = document.getElementById(`cursor-${peer.id}`);
    if (el) {
      el.style.transform = `translate3d(${peer.cursor.x}px, ${peer.cursor.y}px, 0)`;
    }
  }

  removePeerElement(clientId) {
    const el = document.getElementById(`cursor-${clientId}`);
    if (el) el.remove();
  }
}

Step 3: Optimizing for Scale and Production

As your application scales from dozens of concurrent users to tens of thousands, a single Node.js process running WebSockets will hit memory and CPU bottlenecks. Here is how you scale this architecture horizontally:

1. Redis Pub/Sub Adapter

When deploying multiple Node.js instances behind a load balancer (like Nginx or AWS ALB), Client A might connect to Node Instance 1 while Client B connects to Node Instance 2. Without a message broker, Node Instance 1 cannot broadcast cursor updates to clients connected to Node Instance 2.

To solve this, integrate Redis Pub/Sub:

+----------+     WebSocket     +-------------------+                 +-------+
| Client A | ----------------> | Node Instance 1   | --Publish-----> |       |
+----------+                   +-------------------+                 | Redis |
                                                                     | Cluster|
+----------+     WebSocket     +-------------------+                 |       |
| Client B | <---------------- | Node Instance 2   | <---Subscribe-- |       |
+----------+                   +-------------------+                 +-------+

When a message arrives at Node Instance 1, it publishes the event to a Redis channel corresponding to the roomId. All Node instances subscribed to that channel receive the message and forward it to their respective local WebSocket clients.

2. Binary Serialization with Protocol Buffers

JSON serialization is human-readable and easy to debug, but it is verbose and CPU-heavy to parse at high frequencies. For enterprise-grade collaborative apps, consider switching from JSON strings to Protocol Buffers (Protobuf) or MessagePack over WebSocket binary arrays (ws.send(binaryData)). This cuts payload sizes by up to 70%.

3. Heartbeat and Dead Connection Pruning

Network drops do not always trigger clean WebSocket close events immediately (e.g., mobile users losing signal). Implement a server-side heartbeat ping interval to detect and prune dead connections:

const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => {
    ws.isAlive = true;
  });
});

Conclusion

Building a real-time presence and cursor tracking engine in Node.js requires balancing responsiveness with resource efficiency. By leveraging persistent WebSocket connections, in-memory room maps, and careful client-side throttling (requestAnimationFrame or time intervals), you can deliver a buttery-smooth collaborative experience without overloading your infrastructure.

As you grow, introducing Redis Pub/Sub will seamlessly scale your presence cluster across multiple backend nodes, setting the foundation for robust real-time collaboration.

More posts