Ghosts in the Machine: Implementing Real-Time Cursors and Awareness in Yjs
A deep dive into building butter-smooth real-time cursor synchronization and awareness in collaborative frontend editors using Yjs, WebSockets, and spatial optimization.
Ghosts in the Machine: Implementing Real-Time Cursors and Awareness in Yjs
Collaborative editing has evolved from a futuristic novelty into an expected baseline for modern productivity software. When users share a document, canvas, or code editor, seeing a flurry of ghosts—color-coded cursors darting across the screen, avatars popping into view, and selections snapping into place—transforms a solitary tool into a shared workspace.
Underneath this illusion of magic lies a complex orchestration of distributed state management, network protocols, and rendering optimization. If you try to stream every single mouse movement over a raw WebSocket without architectural constraints, you will quickly flood the network, overwhelm your server, and tank your frontend’s frame rate.
In this architectural dive, we will build a production-grade real-time cursor and awareness system using Yjs, WebSockets, and a custom frontend rendering pipeline designed to stay butter-smooth even during frantic multi-user editing sessions.
The Anatomy of Collaborative Awareness
In Yjs, collaboration is split into two distinct conceptual channels:
- Document State (CRDTs): The source of truth for the content itself (text blocks, nodes, attributes). This must eventually converge identically across all peers.
- Awareness State (Ephemeral Ephemeral Data): Transient metadata about the user—their cursor position, active selection, viewport bounds, display name, and color. This data does not need to be stored in the permanent history; if a user disconnects, their ghost vanishes.
Why WebSockets?
While WebRTC handles peer-to-peer data channels well, scaling peer mesh topologies past a few participants becomes mathematically untenable due to upload bandwidth limits. A centralized WebSocket relay server remains the industry standard for distributing both CRDT updates and awareness states to large rooms.
Step 1: Setting up the WebSocket Provider and Awareness Protocol
Let’s initialize our Yjs document and hook it up to a WebSocket server using y-websocket. We will use the built-in awareness instance provided by the client library.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// Initialize the CRDT document
export const doc = new Y.Doc();
// Connect to the signaling/relay server
const wsProvider = new WebsocketProvider(
'wss://your-collaboration-server.com',
'document-room-uuid',
doc
);
// The awareness instance manages ephemeral client states
export const awareness = wsProvider.awareness;
// Set initial local user state
awareness.setLocalState({
user: {
name: 'Alice Developer',
color: '#ffb900',
colorLight: 'rgba(255, 185, 0, 0.2)',
},
cursor: {
anchor: null,
head: null,
},
});
Every connected client has a unique clientId generated by Yjs. The awareness protocol broadcasts state changes whenever a client updates its local state via awareness.setLocalState() or awareness.setLocalStateField().
Step 2: Throttling and Tracking Mouse Movements
If a user moves their mouse continuously across a canvas or editor, triggering an awareness update on every single mousemove or selectionchange event is a performance disaster. It creates hundreds of WebSocket frames per second per user.
We need to throttle these updates using requestAnimationFrame or a targeted rate limiter (e.g., max 30 updates per second) combined with a dirty-flag check.
interface CursorPosition {
anchor: number;
head: number;
x?: number;
y?: number;
}
let lastCursorState: CursorPosition | null = null;
let isUpdateScheduled = false;
export function trackCursorMovement(position: CursorPosition) {
// Store latest coordinates locally
lastKeyCursorState = position;
if (!isUpdateScheduled) {
isUpdateScheduled = true;
window.requestAnimationFrame(() => {
if (lastKeyCursorState) {
awareness.setLocalStateField('cursor', lastCursorState);
}
isUpdateScheduled = false;
});
}
}
By tying our outbound awareness updates to requestAnimationFrame, we naturally synchronize our network chatter with the browser’s paint cycle, preventing redundant frames from hitting the WebSocket stack.
Step 3: Rendering Remote Cursors Efficiently
When remote users move their cursors, the awareness instance fires a change event. We need to listen to this event, extract the remote client states, and render them in our UI layer.
interface RemoteUserStates {
[clientId: number]: {
user: { name: string; color: string; colorLight: string };
cursor: CursorPosition;
};
}
function setupCursorRenderer(containerElement: HTMLElement) {
// Maintain a map of DOM elements for each remote client
const cursorElements = new Map<number, HTMLElement>();
awareness.on('change', ({ added, updated, removed }) => {
const states = awareness.getStates() as Map<number, any>;
const localId = awareness.doc.clientID;
// Handle removed users
removed.forEach((clientId) => {
const el = cursorElements.get(clientId);
if (el) {
el.remove();
cursorElements.delete(clientId);
}
});
// Handle added or updated users
states.forEach((state, clientId) => {
if (clientId === localId) return; // Don't render our own cursor
let cursorEl = cursorElements.get(clientId);
if (!cursorEl && state.user) {
// Create cursor element dynamically
cursorEl = document.createElement('div');
cursorEl.className = 'remote-cursor-pointer';
cursorEl.style.setProperty('--cursor-color', state.user.color);
const label = document.createElement('div');
label.className = 'remote-cursor-label';
label.textContent = state.user.name;
label.style.backgroundColor = state.user.color;
cursorEl.appendChild(label);
containerElement.appendChild(cursorEl);
cursorElements.set(clientId, cursorEl);
}
if (cursorEl && state.cursor && state.cursor.x !== undefined) {
// Translate position using GPU-accelerated transform
cursorEl.style.transform = `translate3d(${state.cursor.x}px, ${state.cursor.y}px, 0)`;
}
});
});
}
Key Performance Considerations
translate3dovertop/left: Changingtopandleftproperties forces the browser to trigger layout recalculations (reflows). Using CSStransform: translate3d(...)keeps animations strictly within the compositor layer, resulting in smooth 60fps rendering.- DOM Pooling: We cache and reuse cursor elements rather than destroying and recreating them on every awareness pulse, keeping garbage collection pauses to a minimum.
Step 4: Viewport Clipping and Spatial Optimization
In large documents or infinite canvases, a room might contain 50 active users, but you may only be viewing a tiny fraction of that space. Rendering all 50 remote cursors off-screen—and processing their updates—wastes precious CPU cycles.
We can implement a spatial viewport culling algorithm to skip rendering cursors that fall outside the user’s visible bounding box.
interface ViewportBounds {
top: number;
bottom: number;
left: number;
right: number;
}
let currentViewport: ViewportBounds = {
top: 0,
bottom: window.innerHeight,
left: 0,
right: window.innerWidth,
};
function isWithinViewport(cursor: { x: number; y: number }, vp: ViewportBounds): boolean {
// Add a buffer margin (e.g., 100px) to prevent pop-in artifacts at edges
const buffer = 100;
return (
cursor.x >= vp.left - buffer &&
cursor.x <= vp.right + buffer &&
cursor.y >= vp.top - buffer &&
cursor.y <= vp.bottom + buffer
);
}
Integrating this check inside our awareness change listener ensures we only update DOM nodes for cursors currently visible to the local user:
if (state.cursor && state.cursor.x !== undefined) {
const visible = isWithinViewport(state.cursor, currentViewport);
cursorEl.style.display = visible ? 'block' : 'none';
if (visible) {
cursorEl.style.transform = `translate3d(${state.cursor.x}px, ${state.cursor.y}px, 0)`;
}
}
Step 5: Handling Stale States and Disconnections
Network drops on mobile devices or sudden browser crashes can leave ghost cursors lingering on screen indefinitely because the disconnect event (wsProvider.disconnect) didn’t have time to fire gracefully.
The Yjs awareness protocol solves this with a built-in heartbeat and TTL (Time-to-Live) mechanism. Clients broadcast a heartbeat ping every 15 seconds. If the WebSocket server or peer fails to receive heartbeats from a client within a specified window, that client’s awareness state is automatically pruned and removed from the active state map.
However, on the application layer, it is also good practice to clear local awareness when the tab loses visibility or unloads:
window.addEventListener('beforeunload', () => {
awareness.setLocalState(null);
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Optionally dim cursor or set status to 'idle'
awareness.setLocalStateField('user', {
...awareness.getLocalState()?.user,
status: 'idle',
});
}
});
Summary Checklist for Production Cursors
To ensure your collaborative frontend application remains performant at scale, remember these core rules:
- Separate Concerns: Keep CRDT document edits strictly separated from ephemeral awareness states.
- Throttle aggressively: Bind cursor tracking to
requestAnimationFrameor fixed interval rate-limiters. - Use GPU acceleration: Always animate cursors using
transform: translate3d()to avoid costly layout reflows. - Cull off-screen elements: Implement spatial viewport checks so you don’t waste DOM operations on invisible remote users.
- Trust the protocol TTL: Let Yjs handle stale peer cleanups automatically via built-in heartbeats.
By combining Yjs’s robust awareness protocol with careful frontend rendering strategies, you can banish jank and deliver a collaborative experience that feels instantaneous, natural, and rock-solid.