Skip to content

Presence and Awareness

advanced15 min read

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:

  1. Who is here? — Avatars, names, online indicators
  2. Where are they? — Cursor positions, scroll position, selected elements
  3. 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.
  }
});
Mental Model

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)
Quiz
Why is presence/awareness state kept separate from CRDT document state in Yjs?

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.

Quiz
What is a good throttle interval for cursor position broadcasts in a collaborative editor?

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);
  }
}
Common Trap

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).

FeatureYjs AwarenessLiveblocksPartyKit
Self-hostedYesNo (managed)Cloudflare Workers
React integrationManualFirst-class hooksManual
Yjs integrationNativeVia @liveblocks/yjsVia y-partykit
PricingFree (self-hosted)Free tier, then per-connectionFree tier, then per-request
Presence featuresBasic (you build the UX)Avatars, cursors, typing out of the boxCustom (you build everything)
ComplexityMedium (manage your own infra)Low (managed service)Medium (write server code, infra managed)
Quiz
For a startup building a collaborative whiteboard MVP, which presence approach minimizes time to launch?

Privacy Considerations

Presence features can be creepy if not designed thoughtfully.

Key Rules
  1. 1Let users opt out of presence sharing — some people don't want others seeing their cursor
  2. 2Don't show exact cursor position in sections the user hasn't explicitly shared (private notes)
  3. 3Anonymize presence in public documents — show 'Anonymous Elephant' not 'john.doe@company.com'
  4. 4Don't log or persist awareness data — it's ephemeral for a reason
  5. 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 doWhat 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
Interview Question

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?