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:
- State Bloat: Every tiny mouse twitch would generate a unique transaction, bloating the binary update vector with historical garbage.
- 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.
+-------------------------------------------------------------+
| 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.