All posts
14 Sep 2026

Beyond Text: Building Real-Time Presence and Cursor Tracking with Yjs

A technical walkthrough of scaling presence, awareness states, and remote cursors alongside Yjs documents, covering network thrashing prevention and ephemeral state management.

Beyond Text: Building Real-Time Presence and Cursor Tracking with Yjs

When developers think of Collaborative software built on CRDTs (Conflict-free Replicated Data Types), text editing is usually the first thing that comes to mind. Libraries like Yjs have revolutionized how we handle concurrent text editing, making document synchronization feel effortless. But modern real-time applications demand more than just shared text: users expect buttery-smooth remote cursors, live selection ranges, active user avatars, and instant indicators of who is typing where.

While Yjs documents excel at persisting structural state (the Document), presence and cursor tracking operate on a fundamentally different axis: ephemeral state. Ephemeral data is high-frequency, low-durability, and inherently volatile. If a user loses connection, their cursor doesn’t need to persist in historical state; it needs to vanish gracefully.

In this technical walkthrough, we will explore how to build a robust, scalable presence and cursor tracking system using Yjs’s built-in Awareness protocol, prevent network thrashing, and handle dropped connections without data loss or UI ghosts.


The Architecture of Awareness: Documents vs. Ephemeral States

To understand why we treat presence differently from document data, we need to look at how Yjs structures state propagation.

A Yjs document (Y.Doc) is optimized for convergence over long periods. Edges can be cut, networks can partition for hours, and when nodes reconnect, the CRDT engine merges the state deterministically. Every operation is recorded, hashed, and applied to a shared transaction log.

If you attempted to map remote cursor positions or mouse coordinates directly into a Y.Map inside a document, you would quickly run into severe performance degradation:

  1. State Bloat: Every tiny mouse twitch would generate a unique transaction, bloating the binary update vector with historical garbage.
  2. Garbage Collection Overhead: Even if you frequently cleared old coordinates, the internal tombstone structures of the CRDT would accumulate overhead.

To solve this, Yjs provides a secondary protocol: awareness (managed via y-protocols/awareness). The awareness engine operates on top of the same WebSocket transport layer but bypasses the document’s transaction log. It acts as a distributed key-value store where each connected peer broadcasts its local state alongside a heartbeat timestamp.

code
+-------------------------------------------------------------+
|                        WebSocket Client                     |
|                                                             |
|  +--------------------+             +--------------------+  | 
|  |      Y.Doc         |             |  Awareness Engine  |  | 
|  | (Persistent State) |             | (Ephemeral State)  |  | 
|  +---------+----------+             +---------+----------+  | 
+------------|----------------------------------|------------+
             |                                  |
             +-----------------+----------------+
                               |
                               v
                 +---------------------------+
                 |   Shared WebSocket Server | 
                 +---------------------------+

Initializing and Structuring State

Let’s implement a production-ready presence manager. We will initialize a Yjs document, set up a WebSocket provider (such as y-websocket), and configure the local user’s initial awareness state, including metadata like name, color, and cursor coordinates.

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { Awareness } from 'y-protocols/awareness';

export interface UserPresence {
  name: string;
  color: string;
  cursor: {
    x: number;
    y: number;
    field: string | null;
  } | null;
  lastUpdated: number;
}

// 1. Initialize Document and Provider
const doc = new Y.Doc();
const wsProvider = new WebsocketProvider(
  'wss://your-collaboration-server.com',
  'document-room-xyz',
  doc
);

// 2. Extract the Awareness instance from the provider
const awareness: Awareness = wsProvider.awareness;

// 3. Define our local user profile
const localUser: UserPresence = {
  name: 'Jane Doe',
  color: '#3b82f6',
  cursor: null,
  lastUpdated: Date.now(),
};

// 4. Set our local state into the awareness broadcast engine	awareness.setLocalState(localUser);

Preventing Network Thrashing on High-Frequency Events

Mouse movement events (mousemove) fire rapidly—often 60 to 120 times per second. If you broadcast an awareness update on every single raw mouse event, you will saturate the WebSocket buffer, spike CPU utilization on the server, and overwhelm peer clients.

To prevent network thrashing, we must apply throttling or requestAnimationFrame-based batching to cursor updates.

Implementing RequestAnimationFrame Throttling

Instead of blindly firing updates, we capture the latest coordinates and schedule the network broadcast to sync with the browser’s render cycle.

let pendingCursorUpdate: { x: number; y: number; field: string } | null = null;
let animationFrameId: number | null = null;

function handleMouseMove(event: MouseEvent, fieldName: string) {
  const container = document.getElementById('editor-container');
  if (!container) return;
  
  const rect = container.getBoundingClientRect();
  
  // Calculate relative coordinates inside the editor container
  pendingCursorUpdate = {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
    field: fieldName,
  };

  if (animationFrameId === null) {
    animationFrameId = requestAnimationFrame(() => {
      if (pendingCursorUpdate) {
        const currentState = awareness.getLocalState() as UserPresence;
        if (currentState) {
          awareness.setLocalState({
            ...currentState,
            cursor: pendingCursorUpdate,
            lastUpdated: Date.now(),
          });
        }
      }
      animationFrameId = null;
    });
  }
}

// Attach listener to your canvas or editor boundary
document.getElementById('editor-container')?.addEventListener('mousemove', (e) => {
  handleMouseMove(e, 'main-canvas');
});

Handling Dropped Connections and Ghost Cursors

In a distributed network, clients do not always disconnect cleanly. A user might close their laptop lid abruptly, lose cellular connectivity, or experience a browser crash. In these scenarios, the client cannot send a “goodbye” message to the WebSocket server.

If left unmanaged, the last known state of the disconnected client remains frozen in the awareness store, resulting in ghost cursors lingering on the screens of active users indefinitely.

The Awareness Timeout Mechanism

Yjs awareness solves this using an internal heartbeat and timeout mechanism. Every client periodically broadcasts its state. If a client stops sending heartbeats (or if the server detects a dropped TCP/WebSocket connection), the server and peer clients purge that clientID from the awareness map and emit a change event.

However, we must also proactively clean up stale states on the client UI layer and handle explicit window unloads:

// Clear awareness state cleanly when the user navigates away or closes the tab
window.addEventListener('beforeunload', () => {
  awareness.setLocalState(null);
});

// Periodic client-side garbage collection sanity check (optional fallback)
const STALE_THRESHOLD_MS = 10000; // 10 seconds

setInterval(() => {
  const states = awareness.getStates();
  const now = Date.now();
  
  states.forEach((state, clientID) => {
    if (clientID === awareness.clientID) return;
    const userState = state as UserPresence;
    
    if (userState && now - userState.lastUpdated > STALE_THRESHOLD_MS) {
      // State is stale; you can trigger custom UI removal if the protocol doesn't clear it
      console.warn(`Client ${clientID} state is stale. Last active: ${userState.lastUpdated}`);
    }
  });
}, 5000);

Rendering Remote Cursors and Managing UI State

Listening to remote awareness updates requires subscribing to the awareness.on('change', ...) event listener. This listener fires whenever any peer joins, updates their state, or drops off.

Here is a complete pattern for synchronizing remote presence states into a React or vanilla DOM rendering loop:

interface RemoteCursorsProps {
  awareness: Awareness;
}

class PresenceRenderer {
  private container: HTMLElement;
  private awareness: Awareness;
  private cursorElements: Map<number, HTMLElement> = new Map();

  constructor(containerId: string, awareness: Awareness) {
    const el = document.getElementById(containerId);
    if (!el) throw new Error('Container not found');
    
    this.container = el;
    this.awareness = awareness;

    // Bind listener
    this.awareness.on('change', this.updateRemoteCursors.bind(this));
  }

  private updateRemoteCursors() {
    const states = this.awareness.getStates();
    const localClientId = this.awareness.clientID;

    // Track currently active IDs to clean up dropped clients
    const activeClientIds = new Set<number>();

    states.forEach((state, clientID) => {
      if (clientID === localClientId) return; // Skip local user
      
      const presence = state as UserPresence;
      if (!presence || !presence.cursor) return;

      activeClientIds.add(clientID);
      let cursorEl = this.cursorElements.get(clientID);

      // Create cursor element if it doesn't exist yet
      if (!cursorEl) {
        cursorEl = document.createElement('div');
        cursorEl.className = 'remote-cursor';
        cursorEl.innerHTML = `
          <div class="cursor-pointer" style="background-color: ${presence.color}"></div>
          <div class="cursor-label" style="background-color: ${presence.color}">${presence.name}</div>
        `;
        this.container.appendChild(cursorEl);
        this.cursorElements.set(clientID, cursorEl);
      }

      // Update position dynamically
      cursorEl.style.transform = `translate3d(${presence.cursor.x}px, ${presence.cursor.y}px, 0)`;
    });

    // Garbage collect elements for clients that have disconnected
    this.cursorElements.forEach((el, clientID) => {
      if (!activeClientIds.has(clientID)) {
        el.remove();
        this.cursorElements.delete(clientID);
      }
    });
  }
}

// Usage initialization
const renderer = new PresenceRenderer('editor-container', awareness);

Styling for Smoothness

To ensure your remote cursors don’t jitter during translation updates, leverage hardware-accelerated CSS properties (transform: translate3d) combined with CSS transitions for smooth interpolation:

#editor-container {
  position: relative;
  overflow: hidden;
}

.remote-cursor {
  position: absolute;
  top: 0;
  left: 0;
  pointer-events: none;
  will-change: transform;
  transition: transform 0.05s linear;
  z-index: 1000;
}

.cursor-pointer {
  width: 12px;
  height: 12px;
  clip-path: polygon(0 0, 0 100%, 35% 65%, 100% 65%);
}

.cursor-label {
  position: absolute;
  top: 14px;
  left: 10px;
  padding: 2px 6px;
  color: #fff;
  font-size: 11px;
  font-family: sans-serif;
  border-radius: 4px;
  white-space: nowrap;
}

Conclusion

Scaling real-time applications requires a clear architectural boundary between persistent document states and ephemeral awareness states. By offloading cursor tracking and user presence to Yjs’s awareness protocol rather than polluting your CRDT document structures, you achieve:

  • Zero structural bloat inside your core document trees.
  • Optimized network bandwidth via requestAnimationFrame throttling and delta payloads.
  • Resilient failure recovery, ensuring dropped network connections and stale sessions don’t clutter user screens with ghost cursors.

With these patterns in place, your applications can scale gracefully to support large multi-user rooms without sacrificing responsiveness or visual fidelity.

More posts