Real-Time Presence & Collaborative Cursors — Chat Prompts
If your B2B SaaS has any shared resource customers collaborate on — documents, projects, dashboards, boards, designs — by 2026 customers expect "I can see who else is here, what they're looking at, and where their cursor is." Notion shows avatars at the top with live focus indicators; Figma shows cursors moving in real-time; Linear shows who's viewing the same issue; Google Docs shows colored cursors. The naive shape: poll the server every 5 seconds for "who's online." Works barely; feels delayed; doesn't scale; battery-drains mobile. The right shape: real-time presence via WebSocket / SSE, with throttled cursor updates, idle detection, focus tracking, graceful disconnects, and per-resource scoping.
This is distinct from Real-Time Collaboration (the broader CRDT-based document collab) and from Activity Feed & Timeline (event log; non-realtime). This is the moment-to-moment presence layer that makes a multiplayer app feel alive.
This chat walks through implementing presence + cursors: data flow, WebSocket / Liveblocks / Pusher patterns, throttling, idle handling, multi-user UX, and the operational realities.
What you're building
- "Who's here" presence indicator (avatars + status)
- Live cursor positions (mouse + keyboard focus)
- Per-resource scoping (this doc; not the whole app)
- Idle / away / focus states
- Selection sharing (text highlights; cell selection)
- Typing / activity indicators
- Graceful join + leave UX
- Throttling + backpressure (don't drown clients)
- Battery-aware on mobile
- Operational handling (network drop; tab change; tab close)
1. Decide the scope
Help me decide what shape of presence to ship.
Three increasingly-deep shapes:
LEVEL 1: SIMPLE PRESENCE (the simplest start)
- "Who's here right now" indicator (avatars at top of resource)
- Updates every 30-60 seconds (polling)
- No cursors; no live updates between users
- Pros: ships in 1-2 weeks
- Cons: feels static; not "alive"
LEVEL 2: REAL-TIME PRESENCE (no cursors)
- Live "who's here" via WebSocket / SSE
- Joins / leaves visible immediately
- Status indicators (active / idle / away)
- No cursor positions
- Pros: 4-6 weeks; modern feel
- Cons: cursors still missing
LEVEL 3: FULL MULTIPLAYER (presence + cursors + selections)
- Live cursor positions visible in real-time
- Selection / highlight sharing
- Typing / activity indicators
- Per-user color + name labels
- Pros: 8-16 weeks; full Notion/Figma feel
- Cons: significant engineering; throttling discipline
LEVEL 4: COLLABORATIVE EDITING (CRDT-based)
- Level 3 + actual concurrent editing of shared documents
- See [Real-Time Collaboration](./real-time-collaboration-chat)
- Pros: full collaborative app
- Cons: months of CRDT engineering
DEFAULT FOR MOST B2B SaaS:
- Year 1: Level 1 (simple presence)
- Year 2: Level 2 (real-time presence)
- Year 3+: Level 3 (cursors) for collaborative surfaces
- Level 4: only if your product IS multiplayer-edited (Notion / Figma shape)
Output: scope decision; explicit boundary.
Output: scope statement.
2. Choose realtime infrastructure
For Level 2+, you need a WebSocket-based realtime layer.
Three approaches:
OPTION A: USE A MANAGED PROVIDER (recommended)
- Liveblocks — purpose-built for multiplayer (presence + cursors + storage)
- Pusher Channels — broadcast pub/sub
- Ably — broadcast + delivery guarantees
- PartyKit — modern edge-native (Cloudflare DO)
- Supabase Realtime — bundled with Supabase
Pros:
- Solved hard problems (scaling, reconnection, presence semantics)
- Free / generous tiers
- Days to integrate (vs weeks DIY)
OPTION B: DIY WEBSOCKETS
- Build your own WebSocket server (Node ws, Bun WS, Vercel WebSockets)
- Use Redis Pub/Sub for fan-out
- Manage reconnection, heartbeat, scaling
Pros:
- Full control
- No vendor lock-in
- Cost (no per-connection fees at scale)
Cons:
- Months of engineering
- Hard problems (presence sync, distributed state, scale)
- Better to defer to providers
OPTION C: SSE (SERVER-SENT EVENTS) — one-way only
- Simpler than WebSockets
- Server pushes to client; client uses HTTP for sends
- Easier to scale (HTTP infrastructure)
Pros:
- Simpler than full WebSockets
- Works behind most proxies / CDNs
- HTTP-native
Cons:
- One-way only (server → client; clients post via HTTP)
- Less efficient for bidirectional cursor traffic
DEFAULT for 2026:
- Multiplayer-product (Level 3+): Liveblocks or PartyKit
- Broadcast / general presence (Level 2): Pusher / Ably / Supabase Realtime
- Edge-aligned: PartyKit
- Bundled with stack: Supabase Realtime if Supabase user
See [Realtime & WebSocket Platforms (Reference)](../../VibeReference/content/backend-and-data/realtime-websocket-platforms.md) for full comparison.
Output: provider chosen; integration started.
Output: tooling decision.
3. Design the presence data flow
Data flow for presence:
Per resource (e.g., document, project, board):
- Topic / room / channel = resource_id
- Each user joins room when viewing
- Each user broadcasts presence updates
- Each user receives updates from other users
Presence state (per user):
{
user_id: 'abc',
display_name: 'Alice',
avatar_url: '...',
color: '#ff5500', // assigned per user per session
status: 'active' | 'idle' | 'away',
cursor: { x: 234, y: 567 }, // for cursor-level (Level 3)
selection: { start: 12, end: 45 }, // for text selection
last_activity: 1734567890,
joined_at: 1734567000,
}
Presence flow:
1. User opens resource → join room (resource_id)
2. Server / provider broadcasts "user joined" to other room members
3. Client subscribes to presence updates from room
4. Client periodically broadcasts own state (throttled)
5. On window-blur: status → 'idle'
6. On 5min idle: status → 'away'
7. On window-close / tab-leave: leave room (graceful disconnect)
8. On network drop: timeout (server detects after N seconds)
Throttling:
- Cursor updates: 30-60 events/second (frame-rate-aligned)
- Selection / status updates: 5-10 events/second
- Heartbeat: 10-30 second interval
- Why: prevents bandwidth flooding + battery drain
Implementation example (Liveblocks):
import { useMyPresence, useOthers } from '@liveblocks/react'
function MyComponent() {
const [myPresence, updateMyPresence] = useMyPresence()
const others = useOthers() // other users in the room
// On mouse move
function handleMouseMove(e: React.MouseEvent) {
updateMyPresence({ cursor: { x: e.clientX, y: e.clientY } })
}
// Render others' cursors
return (
<>
<div onMouseMove={handleMouseMove}>...</div>
{others.map(other => (
<Cursor
key={other.connectionId}
x={other.presence.cursor?.x}
y={other.presence.cursor?.y}
color={other.info.color}
name={other.info.displayName}
/>
))}
</>
)
}
Implement:
1. Presence schema definition
2. Throttling on broadcasts
3. Subscription to others' presence
4. Idle detection (window blur + activity timeout)
5. Graceful join / leave
6. Reconnection on network drop
Output: data flow that scales.
4. Build the "who's here" UI
Avatar list at top of resource:
Layout:
- Stacked avatars (overlapping); 5-10 visible; "+3 more" indicator
- Per-avatar: photo, status indicator (dot), tooltip with name + activity
- Click → drawer with full list + "where they are"
Avatar indicators:
- Border color: per-user assigned color (matches their cursor)
- Status dot: green (active), yellow (idle), gray (away)
- Pulse animation on join (subtle; 1 second)
Tooltip on hover:
- Display name
- Status ("Active 2m ago" / "Idle 8m ago" / "Away 1h ago")
- Currently viewing (e.g., "Section 3" if section-level)
Mobile:
- Reduced count visible (3-5)
- Tap to expand
- Less aggressive animation (battery)
Edge cases:
- 100+ users in one room: show top 10; "+90 more"
- User has no avatar: initials in colored circle
- User leaves mid-view: fade-out animation
- Tab-switching: status updates; doesn't disappear
Implement:
1. AvatarStack component
2. UserAvatar with status dot
3. Tooltip on hover
4. Drawer with full list
5. Mobile-specific layout
Output: UI that respects mobile + scales.
5. Build cursor rendering (Level 3)
Live cursor rendering:
Each other user has:
- Cursor position (x, y in document coords)
- Color (assigned per session)
- Name label
Component:
function Cursor({ x, y, color, name }) {
return (
<div
className="cursor"
style={{
position: 'absolute',
left: x,
top: y,
pointerEvents: 'none', // don't block clicks
zIndex: 10000,
}}
>
<CursorIcon color={color} />
<CursorLabel color={color}>{name}</CursorLabel>
</div>
)
}
Smooth interpolation:
- Cursor positions arrive at 30-60Hz
- Without smoothing: jerky
- Use requestAnimationFrame + easing (lerp)
- Or: rely on Liveblocks' built-in smoothing
Coordinate systems:
- Document-local coordinates (not screen)
- Convert from clientX/clientY to document position
- On scroll / zoom: re-render cursors
Performance:
- 50+ cursors: GPU-accelerated transform
- Use transform: translate3d() (compositor-only)
- Don't trigger layout / paint per cursor
Keyboard focus:
- Track which element user is focused on (input, button)
- Show subtle indicator on that element for others
- Useful for forms / shared editing
Selection sharing:
- Text editor: highlight selected range with user's color
- Spreadsheet / cells: outline selected cell
- Use Range API or framework-specific (TipTap selection / Yjs selection)
Implement:
1. Cursor component
2. Smooth interpolation
3. Coordinate transformation
4. Performance-optimized rendering
5. Selection rendering (per surface type)
6. Idle cursor handling (fade out after N seconds)
Output: cursors that feel native.
6. Idle + activity detection
Idle detection rules:
Active:
- Mouse moved within last 30 seconds
- Keyboard pressed within last 30 seconds
- Click / scroll within last 60 seconds
Idle:
- No activity for 1-3 minutes
- Tab is visible
Away:
- No activity for 5-10 minutes
- OR tab is hidden (visibilitychange === 'hidden')
- OR window blurred
Disconnected:
- WebSocket connection lost
- After 30 seconds: remove from "who's here"
Implementation:
let lastActivity = Date.now()
let status: 'active' | 'idle' | 'away' = 'active'
function recordActivity() {
lastActivity = Date.now()
if (status !== 'active') {
status = 'active'
broadcastStatus(status)
}
}
document.addEventListener('mousemove', throttle(recordActivity, 1000))
document.addEventListener('keydown', recordActivity)
document.addEventListener('click', recordActivity)
document.addEventListener('scroll', throttle(recordActivity, 1000))
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
status = 'away'
broadcastStatus(status)
} else {
recordActivity()
}
})
setInterval(() => {
const idleMs = Date.now() - lastActivity
if (idleMs > 5 * 60 * 1000 && status !== 'away') {
status = 'away'
broadcastStatus(status)
} else if (idleMs > 1 * 60 * 1000 && status === 'active') {
status = 'idle'
broadcastStatus(status)
}
}, 5000)
Battery considerations:
- Mobile: reduce heartbeat to 30-60 seconds (vs 10-15 desktop)
- Mobile: no cursor broadcasts (pointless)
- Suspend on hidden tab (no broadcasts)
Implement:
1. Activity event listeners
2. Status state machine (active / idle / away / disconnected)
3. Visibility-change handling
4. Mobile throttling
5. Suspend on hidden
Output: presence that respects user state.
7. Operational + edge cases
Walk me through:
1. User has multiple tabs / devices on same resource
- Each connection is separate; both shown in "who's here"
- Optionally: deduplicate by user_id (show once with multi-device indicator)
- Cursors: only show one (active tab) to avoid confusion
2. User's WebSocket connection drops (flaky wifi)
- After 5-10 seconds: status → 'reconnecting' (visible to self)
- After 30 seconds: server removes from room; broadcast 'left'
- On reconnect: re-join room; re-broadcast presence
3. Browser tab closed (page unload)
- beforeunload event: send "leave" message (best-effort)
- Server: timeout removes from room after N seconds
- Use sendBeacon API (more reliable than fetch on unload)
4. 100+ concurrent users in one room
- Avatar overflow ("+90 more")
- Selectively render cursors: only top 10 active or only those in viewport
- Throttle cursor broadcasts more aggressively
- Performance budget per cursor
5. Different resource types (doc vs board vs spreadsheet)
- Each has own coordinate system
- Cursor adapts (text caret in doc; pointer in board; cell border in spreadsheet)
- Per-surface implementation
6. Permission changes mid-session
- User loses access while viewing → eject from room
- Other users see them leave (with indicator)
- Audit log captures
7. User's avatar / name changes
- Broadcast info update
- Other clients re-render with new info
8. User on slow / low-bandwidth connection
- Throttle outbound (cursor) more aggressively
- Show others' cursors at reduced rate
- Status: "low connection"
9. Stale cursor (user idle 30s with cursor stuck)
- Fade out cursor after 30 seconds idle
- Hide entirely after 60 seconds
- Re-show on activity
10. Embedded view / iframe
- Same room? (depends; usually yes)
- Cross-origin: handle CORS for WebSocket
11. Cross-tenant boundary
- Each room scoped to (workspace_id, resource_id)
- NEVER share rooms across workspaces
- Strict permission check on room-join
12. Massive cursor flood (browser bug; user dragging fast)
- Server-side rate limit per user (60-100 events/sec hard cap)
- Drop excess; don't queue
13. Multi-region latency
- Cursors from far-away users feel laggy
- Acceptable: < 200ms RTT
- Use edge-deployed realtime (PartyKit / Cloudflare DO)
14. Test with multiple users
- Local dev: hard to test alone
- Use Liveblocks playground OR multiple browsers / incognito tabs
- Automated tests with multiple Puppeteer / Playwright instances
For each: code change + UX impact + ops consideration.
Output: ops that handle real users.
8. Recap
What you've built:
- Realtime infrastructure chosen (Liveblocks / PartyKit / Pusher / etc.)
- Presence schema + data flow
- "Who's here" avatar UI
- Live cursor rendering (Level 3)
- Selection sharing (per surface)
- Idle / away / disconnect handling
- Throttling + backpressure
- Mobile-aware (battery-aware)
- Multi-tab / multi-device handling
- Permission-aware (eject on revoke)
What you're explicitly NOT shipping in v1:
- Voice / video presence (different category; see Real-Time Collaboration for full multiplayer)
- Pointer-locked games (different domain)
- Spatial / metaverse presence (overkill for B2B SaaS)
- Custom emoji reactions on cursors (defer; nice-to-have)
Ship Level 1 in 1-2 weeks. Level 2 in 4-6 weeks. Level 3 (cursors) in 8-16 weeks if collaborative surfaces matter.
The biggest mistake teams make: building DIY WebSockets when Liveblocks / PartyKit / Pusher would have shipped 5x faster with better engineering.
The second mistake: not throttling cursor broadcasts. 1000 events/sec per user → bandwidth + battery + server load.
The third mistake: skipping idle detection. Stale cursors look broken; fade out after inactivity.
See Also
- Real-Time Collaboration — broader (CRDT-based collaborative editing)
- Activity Feed & Timeline — adjacent (event log, non-realtime)
- In-App Notifications — adjacent (notifications)
- Comments, Threading, Mentions — adjacent (collaborative annotation)
- WebSocket / SSE Implementation — depended-upon
- Multi-Tenancy — depended-upon (room scoping)
- Roles & Permissions — depended-upon
- Performance Optimization — adjacent (cursor rendering perf)
- Mobile Push Notifications — adjacent
- Realtime & WebSocket Platforms (Reference) — tooling
- Multi-Region Deployment — pairs (latency for global users)
- Background Jobs & Queue Management — adjacent