Back to System Design
System Design
medium
mid

How would you handle real time cursor position, typing indicators, and presence in a collaborative app?

Broadcast lightweight ephemeral events (cursor x/y, typing, presence) over a WebSocket, throttle cursor updates (~30-60ms) and interpolate on the receiving side, use heartbeats + timeouts for presence, and keep this transient data out of your persistent document state.

6 min read·~20 min to think through

Cursors, typing indicators, and presence are ephemeral, high-frequency, low-stakes data — the opposite of document state. Designing them well is about controlling bandwidth and treating loss as acceptable.

Transport

A WebSocket per client into a room/document channel. Each client publishes its own ephemeral state; the server fans out to others in the room. (Managed options: Liveblocks, Yjs awareness, PartyKit, Ably.)

Cursor position

  • Publish { userId, x, y } (document-relative coords, not viewport pixels).
  • Throttle sends to ~20–30 fps (every 30–50ms). Don't send every mousemove — that's hundreds of events/sec.
  • Interpolate on receive — tween the remote cursor from its last position to the new one over the throttle interval, so it glides instead of teleporting.
  • Send in document coordinates so it maps correctly across different scroll/zoom states.

Typing indicators

  • On first keypress, send typing: true; debounce a typing: false after ~2–3s of inactivity (and on blur/send).
  • Coalesce — "Alice and 2 others are typing."
  • It's fine to drop these; they're hints.

Presence (who's here)

  • On join, broadcast presence; on leave, broadcast departure.
  • But disconnects aren't always clean — rely on heartbeats: each client pings periodically; the server marks a user offline if no ping within a timeout. The WebSocket close event handles graceful exits; the timeout handles crashes/network drops.
  • Track per-connection, and dedupe a user open in multiple tabs.

The key principle: ephemeral ≠ document state

This data is transient awareness, not part of the saved document. Keep it in a separate channel/store (Yjs calls it "awareness"). It should never be persisted, never go through your conflict-resolution/OT/CRDT pipeline, and losing a few updates must be harmless.

Other concerns

  • Bandwidth — throttle, batch multiple users' updates server-side, send deltas.
  • Scale — rooms backed by pub/sub; presence state in Redis with TTLs.
  • Cleanup — remove cursors on disconnect; fade them out gracefully.
  • Privacy/UX — show names/colors/avatars; let users go invisible.

Follow-up questions

  • Why throttle and interpolate cursor updates instead of sending every mousemove?
  • Why can't you rely solely on the WebSocket close event for presence?
  • Why must ephemeral state stay out of the document/OT pipeline?
  • How do you handle one user open in multiple tabs?

Common mistakes

  • Sending every mousemove event — flooding the connection.
  • Relying only on disconnect events for presence, so crashed clients linger forever.
  • Routing cursor/typing data through document conflict resolution.
  • Not interpolating, so remote cursors teleport jerkily.

Performance considerations

  • Cursor events are the bandwidth risk — throttle to ~20-30fps, send deltas, batch server-side fan-out. Interpolation on receive lets you send less while still looking smooth. Cap rendered cursors in huge rooms. Presence via Redis TTLs scales horizontally.

Edge cases

  • Ungraceful disconnects (crash, network drop, sleep).
  • Same user in multiple tabs/devices.
  • Hundreds of users in one room — too many cursors to render.
  • Coordinate mapping across different zoom/scroll states.

Real-world examples

  • Figma / Google Docs live cursors and presence avatars.
  • Yjs awareness protocol; Liveblocks presence API.

Senior engineer discussion

Seniors immediately separate ephemeral awareness from persistent document state and design two channels. They cover throttle+interpolate for bandwidth-vs-smoothness, heartbeat-based presence because close events are unreliable, multi-tab dedup, and horizontal scale via pub/sub + Redis TTLs. Build-vs-buy (Liveblocks/Yjs) is a natural mention.

Related questions