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
- Text Editors - Text cursor sync
- Canvas Apps - Pointer sync