Skip to content

Presence

Presence is “who’s here right now, and what are they doing”. Cursor position on a shared doc, an avatar in a room header, a typing indicator — everything real-time-collaboration-shaped. plasma ships presence out of the box, riding on the same SyncCoordinator Durable Object that fans out WebSocket pokes.

Two ends: the client subscribes with a userInfo payload and an onPresence callback; the server exports SyncCoordinator from the Worker.

The simplest wiring for a presence-only use case is the usePresence React hook — no need to construct a full plasma client if you’re not doing sync yet:

import { usePresence } from "@sh1n4ps/plasma-react"
function DocHeader({ docId, user }) {
const { peers, me, connecting } = usePresence({
url: "wss://api.example.com/sync/coordinator",
room: `doc-${docId}`,
token: env.PLASMA_COORDINATOR_TOKEN,
userInfo: { name: user.name, colour: user.colour },
})
return <AvatarList peers={peers} me={me} connecting={connecting} />
}

For sync + presence (the full app case), attach presence via createWebSocketSubscription:

import { createPlasmaClient, createWebSocketSubscription } from "@sh1n4ps/plasma-client"
const plasma = createPlasmaClient({
schema, mutators,
endpoint: "/sync",
clientGroupID: user.id,
schemaVersion: SCHEMA_VERSION,
getContext: async () => ({ userId: user.id }),
subscribe: createWebSocketSubscription({
url: "wss://api.example.com/sync/coordinator",
userInfo: {
name: user.name,
colour: user.colour,
cursor: null, // updated later via a room broadcast
},
onPresence: (entries) => {
// Called every time someone joins, leaves, or updates userInfo.
// `entries` includes THIS client — filter it out if the UI
// shouldn't show yourself as a peer.
setPeers(entries.filter((e) => e.clientID !== plasma.clientID))
},
}),
})

The PresenceEntry shape is:

{ clientID: string, userInfo: unknown }

userInfo is passed as JSON to the coordinator and forwarded verbatim to every subscriber. Any JSON-serialisable value works — null, a primitive, an object, an array.

SyncCoordinator is the Durable Object that handles both pokes and presence. Export it and bind it in wrangler.jsonc:

worker.ts
export { SyncCoordinator } from "@sh1n4ps/plasma-server"
{
"durable_objects": {
"bindings": [
{ "name": "COORDINATOR", "class_name": "SyncCoordinator" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["SyncCoordinator"] }
]
}

That’s the entirety of the server-side setup. plasma routes WebSocket upgrade requests at /sync/coordinator to the DO on your behalf.

createWebSocketSubscription — every option

Section titled “createWebSocketSubscription — every option”
Option Default What
url required Full wss://…/sync/coordinator URL
room "global" Room to subscribe to. Sent as ?room= on the WebSocket upgrade
token Coordinator shared secret (PLASMA_COORDINATOR_TOKEN on the DO). Function form is called on each connect. Omit only when the DO is deliberately public — usually you want this set
clientID (random UUID) Presence identifier. Omit to let the DO assign one; supply your own to correlate with plasma’s clientID
userInfo Any JSON — cursor / colour / name / whatever the app broadcasts
onPresence (entries: PresenceEntry[]) => void. Fires on every join / leave / userInfo change with the full current set
initialReconnectDelayMs 500 First reconnect backoff after a close
maxReconnectDelayMs Cap on the reconnect backoff. Omit for no cap
WebSocketImpl globalThis.WebSocket For tests — inject a fake

PresenceEntry shape: { clientID: string, userInfo: unknown }.

Presence is scoped per room. The default room is "global", which means every connected client on the same coordinator DO sees every other one — fine for a small app, wrong once you have distinct documents / channels / rooms.

To scope per document (or per channel, or whatever your app’s unit of collaboration is), pass room on the client:

createWebSocketSubscription({
url: "wss://api.example.com/sync/coordinator",
room: `doc-${docId}`,
userInfo: { name: user.name, colour: user.colour },
onPresence: (entries) => setPeers(entries),
})

The value ends up as ?room=doc-{docId} on the WebSocket upgrade, and the coordinator’s onPresence broadcast is scoped to that room only. Two users editing the same document see each other; two users on different documents don’t.

The onPresence callback fires when:

  • Someone joins the room — the coordinator hibernates cleanly, so a new WebSocket subscription is treated as a join.
  • Someone leaves the room — WebSocket close event, or hibernation timeout.
  • A subscriber changes their userInfo — reconnect with the new payload, or push through a follow-up message (see “Broadcasting updates” below).

The callback receives the full presence list every time. Diff in the app layer if you need to distinguish who joined vs who left.

To change your userInfo (e.g. move the cursor), reconnect with the new payload. The coordinator sees the new join as a fresh subscribe with new userInfo and rebroadcasts. That’s fine for low-frequency updates (name / avatar / colour), but for a moving cursor you’d throttle before broadcasting — requestAnimationFrame is a reasonable pace.

createWebSocketSubscription is fully opt-in. If you omit subscribe from createPlasmaClient, presence is disabled — onPresence never fires and no WebSocket ever opens. This is the right default for apps that don’t need real-time collaboration: you pay zero WebSocket cost, and the poll loop keeps sync working.

The client’s WebSocket transport auto-reconnects with exponential backoff. During the reconnection window, presence goes stale: the last onPresence snapshot you received still reflects who was online. There’s no explicit “connection lost” signal for you to show a “reconnecting…” UI — reach for the client.onError handler and watch for network events as a heuristic.

Presence and userInfo are payload-only — the coordinator doesn’t validate them. If you show a peer’s name, sanitize it first. The same goes for colour, avatar, or anything else you show in a presence UI.