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.

One peer's awareness over a session A peer joins with a name and colour, moves their cursor, renews their state with a heartbeat, then disappears when their laptop closes; thirty seconds later a timeout removes the ghost cursor. One peer's awareness over a session ghost cursor window join: name, colour (0 s) cursor moves (throttled) (5 s) heartbeat: still here (21 s) laptop closes (36 s) timeout: cursor removed (66 s) The timeout bounds how long a vanished peer's cursor stays on other screens
Awareness state needs a lifetime, not just updates.

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.

Awareness messages delivered per second in one room Awareness fan-out grows with the square of room size; sending every mouse movement at sixty per second produces 150,000 deliveries per second for fifty users, while throttling to about twelve per second cuts that fivefold. Awareness messages delivered per second in one room each update fans out to every other member every mousemove (60/s) throttled (~12/s max) 0 50k 100k 150k 5 users 24k 20 users 24k 50 users
Awareness fan-out grows with the square of room size — throttling is not optional.

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.

Awareness vs document updates Document updates are persisted, replayed and individually important, while awareness is never persisted, only its current state matters, it must be throttled and it lives only until a timeout. Awareness vs document updates Document updates Awareness Persisted yes never Replayed on reconnect yes (sync) current state only Every update matters yes latest only Rate control none needed throttle + coalesce Lifetime document's timeout-bound Treating awareness like document data wastes storage and resurrects ghosts
Two kinds of state on one socket, with opposite rules.

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.

Back to Collaborative Editing with CRDTs.