VibeWeek
Home/Grow/Real-Time Presence & Collaborative Cursors — Chat Prompts

Real-Time Presence & Collaborative Cursors — Chat Prompts

⬅️ Back to 6. Grow

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