Awareness (Presence & Cursors)
Awareness is ephemeral collaboration state — who is in a room and where their cursor is — that the server relays to the rest of the room. Unlike graph/text operations, awareness is never part of the CRDT and never persisted: it lives only in memory for the lifetime of each connection.
Use awareness for presence avatars, remote cursors/selections, "who's typing", and similar live-but-disposable signals. Use CRDT operations for anything that must survive a reconnect.
How it differs from CRDT operations
| CRDT operations | Awareness | |
|---|---|---|
| Persisted | Yes (journal / snapshot) | No — in-memory only |
| Conflict handling | CRDT merge | Last write wins (no merge) |
| Relay | Sequenced, then broadcast | Relayed as-is to the room |
| Lifetime | Forever | Until the client disconnects |
Wire messages
Two message types, defined in packages/vuer-rtc/src/serdes.ts:
The state payload shape (user, cursor) is owned by the application — the
relay treats it as an opaque blob and never inspects it. state: null means the
client cleared its awareness (e.g. the editor lost focus) or disconnected.
Client usage
Announce presence right after the WebSocket opens, then apply incoming updates:
The server excludes you from your own room's fan-out and roster, so the roster
only ever contains other clients. Identify a person by state.user.id and
dedupe by it — one user may hold several connections (browser tabs).
Relative cursor positions
vuer-rtc can express a cursor as a relative position — an anchor to a specific character rather than a numeric offset — so it stays on the right spot as the document is edited.
Every character in the text CRDT carries a stable id. A relative position
records the id of the character beside the caret; resolving it looks that
character up in the rope and returns its current offset. Ids are global and a
deleted character leaves a tombstone, so the same anchor resolves to the right
place as text changes around it and on every client. When the anchored character
is not present in a replica yet, resolution returns null.
The helpers live in @vuer-ai/vuer-rtc; their output goes inside the opaque
AwarenessState.cursor blob:
| Helper | Purpose |
|---|---|
encodePos(rope, offset) | Caret offset → RelPos (anchored to the character on its left) |
resolvePos(rope, pos) | RelPos → offset, or null if the anchor is not in this rope |
encodeSelection(rope, anchor, head) | Selection offsets → RelSelection |
resolveSelection(rope, sel) | RelSelection → { anchor, head }, or null |
Resolve a peer's relative cursor whenever their document changes, not only when a new cursor message arrives — the anchor is stable, so re-resolving keeps the caret on its character as text is edited around it. See it in the multiplayer cursor demo.
Server behavior
The RTC server keeps a per-room, in-memory awareness map and:
- On an
awarenessmessage — stores the sender'sstate(or deletes it whennull) and relays the message to every other socket in the room. It never touches the journal. - On connect — sends the newcomer an
awareness-rostercontaining everyone already present, so it can render the current room immediately. - On disconnect — drops the client's state and broadcasts
{ mtype: 'awareness', client, state: null }so the others remove it.
Because it is pure in-memory relay, awareness works even when the server runs without a database (journal-less relay mode).
Backward compatibility
Awareness is additive and degrades cleanly:
- A server that predates it does not recognize the
awarenessmessage and drops it — no roster arrives, so a client shows no presence rather than erroring. - Older clients ignore
awareness/awareness-rostermessages they don't handle.
Reconnects
A client generates a fresh client id on every (re)connect. While
disconnected it may miss other clients' leave messages, so on reconnect it should
reset its roster and rebuild it from the awareness-roster the server sends —
rather than keep possibly-stale entries.