Broadcasting cursor and awareness state #
Live cursors are the feature that makes a collaborative editor feel alive, and the one most likely to make it feel broken. Remote cursors jump to the wrong line after someone else types above them, a colleague who closed their laptop leaves a frozen cursor on screen for minutes, and with twenty people in a document the cursor traffic outweighs the actual edits. Cursor positions, selections, names and “is typing” flags are awareness state: ephemeral, per-user, and superseded by every newer value. Handling it with the same machinery as document edits — ordered, persisted, replayed — is wasteful, and handling it with nothing at all produces ghosts. It needs its own small protocol.
Root cause #
Awareness differs from document data in three ways, and each one drives a bug when it is ignored. It is ephemeral: nobody needs yesterday’s cursor, so persisting or replaying it wastes storage and causes stale cursors to reappear after reconnects. It is high-frequency and superseding: a cursor can move sixty times a second, and only the latest position matters, so sending every movement multiplies bandwidth by room size for no benefit. And it is tied to presence: a cursor is only meaningful while its owner is connected, so a peer that vanishes without a clean close must be timed out, not kept forever.
Position is the fourth trap. A cursor stored as a character offset (“line 12, column 4”) is wrong as soon as anyone inserts text before it. In a CRDT-based editor, positions should be expressed relative to document elements, which move with the text.
Resolution #
Keep awareness in a per-room map of clientId → { state, updatedAt }, separate from the document. Each client sends its whole local state (it is small) when it changes, throttled to a few updates per second, plus a periodic heartbeat so peers know it is still present. Every client removes peers whose state has not been renewed within a timeout, and the server broadcasts an explicit removal when a connection closes. Positions are encoded relative to document content.
import * as Y from 'yjs';
const SEND_INTERVAL_MS = 80; // ≤ ~12 updates/s per client, plenty for smooth cursors
const HEARTBEAT_MS = 15_000; // renew state even when nothing changes
const PEER_TIMEOUT_MS = 30_000; // drop peers not renewed in this window
interface LocalAwareness {
user: { name: string; color: string };
// Relative positions survive concurrent edits; offsets do not.
cursor?: { anchor: Uint8Array; head: Uint8Array };
typing?: boolean;
}
export class AwarenessChannel {
private local: LocalAwareness;
private peers = new Map<number, { state: LocalAwareness; updatedAt: number }>();
private dirty = false;
private lastSent = 0;
constructor(
private clientId: number,
private send: (msg: object) => void,
private text: Y.Text,
user: LocalAwareness['user'],
private onChange: () => void,
) {
this.local = { user };
setInterval(() => this.flush(true), HEARTBEAT_MS);
setInterval(() => this.expirePeers(), PEER_TIMEOUT_MS / 3);
}
// Called on every selection change in the editor — cheap, never sends directly.
setCursor(anchorIndex: number, headIndex: number) {
this.local.cursor = {
anchor: Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(this.text, anchorIndex)),
head: Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(this.text, headIndex)),
};
this.dirty = true;
this.flush(false);
}
private flush(heartbeat: boolean) {
const now = Date.now();
if (!heartbeat && (!this.dirty || now - this.lastSent < SEND_INTERVAL_MS)) {
if (this.dirty) setTimeout(() => this.flush(false), SEND_INTERVAL_MS); // trailing send
return;
}
this.dirty = false;
this.lastSent = now;
this.send({ type: 'awareness', clientId: this.clientId, state: encodeState(this.local) });
}
receive(msg: { clientId: number; state: string | null }) {
if (msg.state === null) this.peers.delete(msg.clientId); // explicit leave
else this.peers.set(msg.clientId, { state: decodeState(msg.state), updatedAt: Date.now() });
this.onChange();
}
private expirePeers() {
const now = Date.now();
for (const [id, p] of this.peers) if (now - p.updatedAt > PEER_TIMEOUT_MS) this.peers.delete(id);
this.onChange();
}
// Resolve a peer's cursor to a current index, after any edits made since it was sent.
cursorIndex(peerId: number, doc: Y.Doc): number | null {
const c = this.peers.get(peerId)?.state.cursor;
if (!c) return null;
const abs = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(c.head), doc);
return abs?.index ?? null;
}
}
declare function encodeState(s: LocalAwareness): string; // e.g. base64 of relative positions + JSON
declare function decodeState(s: string): LocalAwareness;
The server’s role is small: relay awareness messages to the room without storing them, and when a connection closes, broadcast { type: 'awareness', clientId, state: null } for every client id that connection announced. If you use Yjs, its y-protocols/awareness module implements this exact model — full-state updates, a clock per client, a 30-second timeout and explicit removal — and the server in syncing Yjs documents over WebSockets relays it on the same socket.
Rendering benefits from the same discipline: resolve peer cursors to screen positions once per animation frame rather than once per message, so a burst of awareness updates costs one layout pass. That technique is covered in batching WebSocket updates with requestAnimationFrame.
Edge cases #
Large rooms. Past a few dozen active participants, even throttled cursors are noisy for users and expensive for the server. Show cursors only for users whose cursor is within the visible viewport, or only for the few most recently active, and have the server fan out awareness at a lower rate for large rooms.
Several tabs, one user. Each tab has its own client id and cursor. Group them by user id when rendering presence avatars, but keep separate cursors, since the tabs may show different parts of the document.
Privacy. Awareness reveals what someone is looking at and when they are active. Offer a “hide my cursor” option, and never persist awareness to logs or analytics without consent.
Verification #
Open the document in two browsers. Type several lines above the other user’s cursor and confirm their cursor stays on the same word rather than the same offset. Close one browser’s tab abruptly (kill the process rather than closing the tab normally) and confirm the other browser removes the cursor within the timeout. Then check bandwidth in the Network panel’s WS view while moving the mouse continuously: outbound awareness frames should stay at or below about twelve per second.
Operational checklist #
FAQ #
How often should cursor positions be sent? #
Around 10–15 times per second is enough for smooth-looking cursors, especially with CSS transitions interpolating between positions. Sending every mouse move wastes bandwidth quadratically in room size.
Why do remote cursors jump to the wrong place? #
They are stored as character offsets, which shift when anyone inserts or deletes text before them. Encode positions relative to document elements — Yjs relative positions do this — so they move with the text.
How do I remove a user’s cursor when they disconnect? #
Broadcast an explicit removal from the server when the connection closes, and expire peers whose state has not been renewed within a timeout, which catches connections that died without closing.
Is awareness the same as presence? #
Awareness includes presence (who is here) plus per-user ephemeral state such as cursors and selections. Room-level online lists across a whole product are better served by a server-side presence system, as described in building a WebSocket presence system with Redis.
Related #
- Syncing Yjs Documents over WebSockets — the socket awareness shares.
- Typing Indicators over WebSockets — a closely related ephemeral signal.
- Coalescing High-Frequency WebSocket Updates — server-side throttling.
- Fixing Presence Flapping and Ghost Users — timeouts at product scale.
Back to Collaborative Editing with CRDTs.