Awareness

Implementing presence, cursors, and real-time user status with the Yjs awareness protocol.

Overview

Awareness provides ephemeral state synchronization—data that syncs in real-time but doesn’t persist: cursor positions, user presence, selections, typing indicators.

Basic Setup

const awareness = provider.awareness

awareness.setLocalStateField('user', {
  name: 'Alice',
  color: '#f783ac'
})

Setting Local State

// Set single field (preserves others)
awareness.setLocalStateField('cursor', { x: 100, y: 200 })

// Replace entire state
awareness.setLocalState({
  user: { name: 'Alice', color: '#f783ac' },
  cursor: { x: 100, y: 200 }
})

// Clear state (on disconnect)
awareness.setLocalState(null)

Observing Other Users

awareness.on('change', ({ added, updated, removed }) => {
  console.log('Joined:', added)
  console.log('Updated:', updated)
  console.log('Left:', removed)
  renderPresence()
})

function renderPresence() {
  awareness.getStates().forEach((state, clientId) => {
    if (clientId === yDoc.clientID) return  // Skip self
    renderCursor(state.cursor, state.user, clientId)
  })
}

Cursor Synchronization

// Throttle updates to avoid flooding
const updateCursor = throttle((x: number, y: number) => {
  awareness.setLocalStateField('cursor', { x, y })
}, 50)

function onMouseMove(e: MouseEvent) {
  updateCursor(e.clientX, e.clientY)
}

function onMouseLeave() {
  awareness.setLocalStateField('cursor', null)
}

Text Editor Cursors

Use relative positions so cursors survive text edits:

function updateTextCursor(selection) {
  awareness.setLocalStateField('cursor', {
    anchor: Y.createRelativePositionFromTypeIndex(yText, selection.anchor),
    head: Y.createRelativePositionFromTypeIndex(yText, selection.head),
    user: awareness.getLocalState()?.user
  })
}

Activity Status

let idleTimeout: NodeJS.Timeout

function setActive() {
  awareness.setLocalStateField('status', 'active')
  clearTimeout(idleTimeout)
  idleTimeout = setTimeout(() => {
    awareness.setLocalStateField('status', 'idle')
  }, 60000)
}

document.addEventListener('mousemove', setActive)
document.addEventListener('visibilitychange', () => {
  awareness.setLocalStateField('status', document.hidden ? 'away' : 'active')
})

Typing Indicator

let typingTimeout: NodeJS.Timeout

function onTextInput() {
  awareness.setLocalStateField('activity', { type: 'typing' })
  clearTimeout(typingTimeout)
  typingTimeout = setTimeout(() => {
    awareness.setLocalStateField('activity', null)
  }, 2000)
}

Cleanup

window.addEventListener('beforeunload', () => {
  awareness.setLocalState(null)
})

Performance Tips

  • Throttle updates: Max 10-20 cursor updates/second
  • Minimal state: Don’t put large objects in awareness
  • Conditional updates: Only send when position changes significantly:
if (Math.abs(e.clientX - lastX) > 5 || Math.abs(e.clientY - lastY) > 5) {
  awareness.setLocalStateField('cursor', { x: e.clientX, y: e.clientY })
}

Common Mistakes

Forgetting to filter self:

// WRONG: renders own cursor
awareness.getStates().forEach(state => renderCursor(state))

// CORRECT
awareness.getStates().forEach((state, clientId) => {
  if (clientId !== yDoc.clientID) renderCursor(state)
})

Not cleaning up removed users:

awareness.on('change', ({ removed }) => {
  for (const clientId of removed) {
    removeCursor(clientId)
  }
})

See Also