How would you handle real-time cursor position, typing, and presence indicators
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.
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 atyping: falseafter ~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
closeevent 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.