Presence and Awareness
More Than Just Online Dots
Presence in collaborative apps isn't just a green dot. It's the feeling that you're working with someone, not just in the same file as someone. Good presence answers three questions:
- Who is here? — Avatars, names, online indicators
- Where are they? — Cursor positions, scroll position, selected elements
- What are they doing? — Typing indicator, editing a specific section, idle
Get presence right and collaboration feels like magic — like pointing at something on a shared whiteboard while explaining it to a colleague. Get it wrong and it's creepy, distracting, or useless.
The Yjs Awareness Protocol
Yjs separates document state (CRDTs, persisted) from awareness state (ephemeral, not persisted). Awareness is for transient information that only matters while a user is connected: cursor position, user name, selected elements.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://server.com', 'room', doc);
const awareness = provider.awareness;
awareness.setLocalStateField('user', {
name: 'Alice',
color: '#e91e63',
avatar: '/avatars/alice.jpg',
});
awareness.setLocalStateField('cursor', {
anchor: 42,
head: 42,
});
awareness.on('change', ({ added, updated, removed }: {
added: number[];
updated: number[];
removed: number[];
}) => {
const states = awareness.getStates();
for (const [clientId, state] of states) {
if (clientId === doc.clientID) continue;
// Render remote user's cursor, selection, etc.
}
});
Awareness is like a transparent overlay on top of the document. The document is the whiteboard content (persisted, synced via CRDTs). The awareness layer shows where everyone's hands are pointing (ephemeral, not persisted). When someone disconnects, their pointer disappears. When they reconnect, only the whiteboard content matters — their old pointer position is irrelevant.
Why Separate From Document State?
Awareness is ephemeral by design. Cursor positions from 5 minutes ago are worthless. If you stored them in the CRDT document, they'd be replicated, persisted, and accumulate as garbage. By keeping awareness separate:
- No disk storage for transient data
- No CRDT overhead (no tombstones, no merge logic)
- Automatic cleanup when a client disconnects (timeout-based)
- Different sync cadence (presence updates are more frequent but less critical)
Cursor and Selection Broadcasting
The most visible presence feature: colored cursors showing where other users are editing. Here's how to build it:
interface CursorState {
anchor: number;
head: number;
name: string;
color: string;
}
class CursorManager {
private cursors = new Map<number, CursorState>();
private decorations: Map<number, HTMLElement> = new Map();
constructor(
private awareness: Awareness,
private editorContainer: HTMLElement
) {
this.awareness.on('change', () => this.updateCursors());
}
updateLocalCursor(anchor: number, head: number): void {
this.awareness.setLocalStateField('cursor', { anchor, head });
}
private updateCursors(): void {
const states = this.awareness.getStates();
const activeCursors = new Set<number>();
for (const [clientId, state] of states) {
if (clientId === this.awareness.doc.clientID) continue;
if (!state.cursor || !state.user) continue;
activeCursors.add(clientId);
this.cursors.set(clientId, {
anchor: state.cursor.anchor,
head: state.cursor.head,
name: state.user.name,
color: state.user.color,
});
this.renderCursor(clientId);
}
for (const [clientId] of this.cursors) {
if (!activeCursors.has(clientId)) {
this.removeCursor(clientId);
this.cursors.delete(clientId);
}
}
}
private renderCursor(clientId: number): void {
const cursor = this.cursors.get(clientId);
if (!cursor) return;
let el = this.decorations.get(clientId);
if (!el) {
el = document.createElement('div');
el.className = 'remote-cursor';
el.setAttribute('aria-hidden', 'true');
this.editorContainer.appendChild(el);
this.decorations.set(clientId, el);
}
el.style.setProperty('--cursor-color', cursor.color);
el.dataset.userName = cursor.name;
}
private removeCursor(clientId: number): void {
const el = this.decorations.get(clientId);
if (el) {
el.remove();
this.decorations.delete(clientId);
}
}
}
Selections (highlighted ranges) follow the same pattern: the anchor and head positions define the selection range. When anchor equals head, it's a cursor. When they differ, it's a selection that should be rendered as a colored highlight.
Throttling: Not Every Mouse Move
Here's a mistake that will tank your performance: broadcasting cursor position on every mousemove or selectionchange event. A user moving their mouse generates 60+ events per second. Multiply by 20 collaborators, and you're processing 1,200+ presence updates per second.
function throttle<T extends (...args: unknown[]) => void>(
fn: T,
intervalMs: number
): T {
let lastCall = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
const now = Date.now();
const remaining = intervalMs - (now - lastCall);
if (remaining <= 0) {
lastCall = now;
fn(...args);
} else if (!timer) {
timer = setTimeout(() => {
lastCall = Date.now();
timer = null;
fn(...args);
}, remaining);
}
}) as T;
}
const updateCursor = throttle((anchor: number, head: number) => {
awareness.setLocalStateField('cursor', { anchor, head });
}, 50);
editor.on('selectionUpdate', ({ selection }) => {
updateCursor(selection.anchor, selection.head);
});
50ms throttle is the sweet spot for cursor updates. It's fast enough to feel real-time (20 updates/second) but reduces traffic by 60% compared to unthrottled. For less critical presence (scroll position, active section), 200-500ms is fine.
Typing Indicators and Activity States
Beyond cursors, presence includes what the user is doing:
type ActivityState = 'idle' | 'typing' | 'selecting' | 'viewing';
class ActivityTracker {
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private currentState: ActivityState = 'viewing';
constructor(
private awareness: Awareness,
private idleTimeout = 5000
) {}
reportActivity(type: 'keystroke' | 'selection' | 'scroll'): void {
const newState: ActivityState =
type === 'keystroke' ? 'typing' :
type === 'selection' ? 'selecting' : 'viewing';
if (this.currentState !== newState) {
this.currentState = newState;
this.awareness.setLocalStateField('activity', newState);
}
this.resetIdleTimer();
}
private resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => {
this.currentState = 'idle';
this.awareness.setLocalStateField('activity', 'idle');
}, this.idleTimeout);
}
}
Be careful with "typing" indicators in collaborative editors. In a two-person chat, "Alice is typing..." makes sense. In a 20-person document editor, "Alice, Bob, Charlie, Diana, and 16 others are typing..." is noise. Scale your presence indicators: show typing status only for users editing the same section or paragraph as the viewer.
Presence with Liveblocks and PartyKit
If you don't want to build presence infrastructure yourself, two excellent options:
Liveblocks
Liveblocks provides a managed presence API with React hooks:
import { useMyPresence, useOthers } from '@liveblocks/react';
function Cursors() {
const [myPresence, updateMyPresence] = useMyPresence();
const others = useOthers();
const handlePointerMove = (e: React.PointerEvent) => {
updateMyPresence({
cursor: { x: e.clientX, y: e.clientY },
});
};
return (
<div onPointerMove={handlePointerMove}>
{others.map(({ connectionId, presence }) => {
if (!presence.cursor) return null;
return (
<Cursor
key={connectionId}
x={presence.cursor.x}
y={presence.cursor.y}
color={presence.color}
/>
);
})}
</div>
);
}
Liveblocks also integrates with Yjs (@liveblocks/yjs), giving you managed infrastructure for both document CRDTs and presence.
PartyKit
PartyKit runs your collaboration logic on Cloudflare Workers:
import type { Party, Connection } from 'partykit/server';
export default class PresenceServer implements Party.Server {
private cursors = new Map<string, { x: number; y: number; name: string }>();
onMessage(message: string, sender: Connection) {
const data = JSON.parse(message);
this.cursors.set(sender.id, data);
this.room.broadcast(
JSON.stringify({
type: 'cursors',
cursors: Object.fromEntries(this.cursors),
}),
[sender.id]
);
}
onClose(connection: Connection) {
this.cursors.delete(connection.id);
this.room.broadcast(
JSON.stringify({
type: 'cursors',
cursors: Object.fromEntries(this.cursors),
})
);
}
}
PartyKit gives you more control (you write the server logic) while handling the infrastructure (WebSocket connections, global deployment, scaling).
| Feature | Yjs Awareness | Liveblocks | PartyKit |
|---|---|---|---|
| Self-hosted | Yes | No (managed) | Cloudflare Workers |
| React integration | Manual | First-class hooks | Manual |
| Yjs integration | Native | Via @liveblocks/yjs | Via y-partykit |
| Pricing | Free (self-hosted) | Free tier, then per-connection | Free tier, then per-request |
| Presence features | Basic (you build the UX) | Avatars, cursors, typing out of the box | Custom (you build everything) |
| Complexity | Medium (manage your own infra) | Low (managed service) | Medium (write server code, infra managed) |
Privacy Considerations
Presence features can be creepy if not designed thoughtfully.
- 1Let users opt out of presence sharing — some people don't want others seeing their cursor
- 2Don't show exact cursor position in sections the user hasn't explicitly shared (private notes)
- 3Anonymize presence in public documents — show 'Anonymous Elephant' not 'john.doe@company.com'
- 4Don't log or persist awareness data — it's ephemeral for a reason
- 5Consider 'do not disturb' mode where the user appears offline to others
Think about these scenarios:
- A manager opens a shared performance review document. Should the reviewed employee see the manager's cursor on their section?
- A user is browsing a shared document at 2 AM. Should their "active" status be visible?
- A user is reading the beginning of a document but everyone can see their cursor at the top — implying they haven't read further.
The technical implementation is easy. The ethical design is hard. Default to privacy — let users opt in to presence sharing, not opt out.
| What developers do | What they should do |
|---|---|
| Broadcasting cursor position on every mousemove event Unthrottled presence updates create unnecessary network traffic and can cause performance issues with many collaborators. 50ms (20fps) is perceptually indistinguishable from real-time for cursor movement. | Throttle to 50ms for cursors, 200ms+ for scroll/viewport |
| Storing presence data in the CRDT document Presence is inherently transient. Storing it in the document adds CRDT overhead, persists useless data, and creates tombstone bloat from constantly deleted old cursor positions. | Use a separate ephemeral channel (Yjs awareness, Liveblocks presence) |
| Showing all users' cursors regardless of document size In a 50-page document with 15 collaborators, showing all cursors is visual noise. Scope presence to the user's current viewport or section for a cleaner experience. | Show cursors only for users in the same viewport or section |
Design: Figma-Style Presence
Design the presence system for a collaborative design tool like Figma. Requirements: show colored cursors on a 2D canvas (not text), display user avatars at the cursor, show selection rectangles when users select shapes, handle 50 concurrent users on one canvas, and show a "following" mode where you can watch another user's viewport. How do you handle: cursor interpolation (smooth movement between updates), z-ordering of cursor labels, and the case where 10 users' cursors cluster in the same area?