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.
Wiring
Section titled “Wiring”Two ends: the client subscribes with a userInfo payload and an
onPresence callback; the server exports SyncCoordinator from
the Worker.
Client
Section titled “Client”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.
Server
Section titled “Server”SyncCoordinator is the Durable Object that handles both pokes
and presence. Export it and bind it in wrangler.jsonc:
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.
What triggers a presence update
Section titled “What triggers a presence update”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.
Broadcasting updates
Section titled “Broadcasting updates”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.
When the WebSocket isn’t wired
Section titled “When the WebSocket isn’t wired”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.
Fallback if the WebSocket drops
Section titled “Fallback if the WebSocket drops”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.
Anonymous users
Section titled “Anonymous users”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.
What to read next
Section titled “What to read next”- Devtools — inspect the presence snapshot at runtime through the panel
- Multi-region — presence rooms are region-local; cross-region presence is a v1.1 candidate
- Concepts / clientID vs clientGroupID — the identity model that scopes rooms