Presence
Who is in this document right now — peer-to-peer awareness for live cursors, a Redis-backed roster that spans Node instances, and the different questions each layer can answer.
"Who is online" is two different questions with two different answers, and the library answers both in different places. Getting them mixed up is the usual reason presence looks broken.
Layer one — awareness, in the browser
Yjs ships an awareness protocol alongside document sync: a small ephemeral state each client broadcasts to the others in the same room. Cursor position, selection, display name, colour. It never touches the server's memory in any meaningful way, it is not persisted, and it disappears the moment a tab closes.
const { peers, status } = useAwareness({ docName })
// peers: [{ clientId, userId, name, avatarUrl }] — everyone except youThis is the layer that draws live cursors and the avatar stack. It is instant, it is free, and it
needs zero configuration. Publish your own identity through the user option (or setLocalState),
read peers to render everyone else, and let the editor extension add the cursor —
CollaborationCaret in Tiptap writes the local caret into the same channel:
const { peers } = useAwareness({
docName,
user: { userId: me.id, name: me.name, avatarUrl: me.avatarUrl },
})That user is not optional in practice: peers reads it off other clients' awareness state, so
a room where nobody publishes one is a row of anonymous client ids.
Its limit is exactly its nature: awareness only knows about clients connected to the same document room, through the same process. Two Node instances behind a load balancer each hold their own room, and neither knows the other exists.
Layer two — the server roster, across instances
The second question is asked from the server, and it is a different one: does this document have anyone in it right now, from anywhere. Locking a resource, deciding whether to send a "someone edited your document" notification, showing an occupancy badge in a list — none of those can be answered from one browser's awareness state.
Set redisUrl and the provider spins up a Redis-backed presence store, hands it to the manager, and
the manager hands it to the driver — the driver records a join on connect and a leave on disconnect,
and the manager aggregates:
export default defineConfig({
engine: 'yjs',
redisUrl: env.get('REDIS_URL'),
// ...
})import collaboration from '@adonis-agora/collaboration/services/main'
const online = await collaboration.current.listPresence({ docName })
// → [{ userId, name, avatarUrl }] across every Node instanceEntries carry a 60-second TTL, refreshed on join. That is a deliberate safety net: a process killed mid-connection never runs its disconnect handler, and without an expiry its users would look online forever. The cost is that the roster is eventually consistent — a hard crash leaves a ghost for up to a minute.
Without redisUrl, listPresence returns an empty array rather than throwing. Single-instance
development keeps working; you just get nothing from the layer you did not configure.
Choosing a layer
| You want to | Use | Because |
|---|---|---|
| Draw live cursors and selections | useAwareness | it updates on every mouse move, peer to peer |
| Show the avatar stack in the editor | useAwareness | it is already there, with no round trip |
| Show "3 people editing" in a document list | listPresence | the list page has no socket to those documents |
| Decide something server-side | listPresence | the server cannot read a browser's awareness |
| Work correctly behind more than one instance | listPresence | awareness is per-process |
They are not redundant, and they will disagree
Awareness is richer and faster; the Redis roster is broader and slower. A user who just closed a tab can be gone from awareness and still in the roster for up to a minute. Show whichever one answers the question the UI is actually asking, and do not try to reconcile them into one number.
Feeding the metadata
Presence entries are only as useful as what you put in them. The name and avatar come from the connection context, which comes from the token endpoint's resolved user:
routes: {
resolveUser: (ctx) => ({ id: String(ctx.auth.user!.id), name: ctx.auth.user!.fullName }),
}Without a name, listPresence gives you ids and your UI has to resolve them itself — a query per
render of a list that was supposed to be cheap. Set it once, here.
One roster entry per user, per document
The roster is keyed by connection: every socket is tracked separately, so a document with several simultaneous editors lists all of them, and a user with two tabs open leaves the roster only when their last socket closes. (Before 0.10.0 it was one slot per document, which under-reported the room and could evict the wrong person on disconnect.)
Comments
Comments anchored to a place inside a document — the three anchor kinds, what a space is for, the create/resolve/remove lifecycle, and why comments are relational data rather than CRDT state.
Engines
The CRDT backend behind a document — what a driver owns, how Yjs, Automerge and the edge engines compare, how a document picks one, and what changes in your code when it does.