Presence
Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor — client-supplied and relayed verbatim; identity fields belong in the server-side resolveMember hook). Works cross-instance.
@voltro/plugin-presence answers "who's here right now". A client heartbeats into a channel; the roster lists everyone whose heartbeat is fresh. Held in memory, owner-partitioned: every member belongs to exactly the replica holding its WebSocket, so concurrent writes to one key are impossible by construction and there is no table, no CRDT and no coordinator.
Wiring
// app.config.ts
import { presencePlugin } from '@voltro/plugin-presence'
export default {
type: 'api' as const, name: 'api',
plugins: [presencePlugin({ timeoutMs: 30_000 })], // online window after the last heartbeat
}Contributes three routes: presence.heartbeat, presence.list, presence.leave. No table — a member is held by the replica that owns its connection.
A client that vanishes without calling leave (a closed laptop, a dropped network, a crashed tab) is removed by a sweep after timeoutMs, and the removal is broadcast so every other replica drops it too. Each replica sweeps only its OWN members: another owner's timestamps are on another clock, and a replica that has gone is dropped whole by instance membership rather than guessed at. The sweep runs at a third of the timeout, so a vanished member is gone within roughly 1.3× the window.
Client hook
import { usePresence } from '@voltro/plugin-presence/web'
const Room = ({ channel }: { channel: string }) => {
// Heartbeats while mounted, leaves on unmount, returns the live roster.
const members = usePresence(channel, { meta: { status: 'typing' } })
return <div>{members.length} online: {members.map((m) => m.key).join(', ')}</div>
}
usePresence(channel, opts) heartbeats on an interval (heartbeatMs, default 15s) and publishes per-member meta (status, cursor position, …). The roster is push-driven — presence.list is a reactive plugin query whose source: is a reactivity channel, so the framework pushes a fresh roster over the subscription transport with NO client polling. key defaults to the subject id; pass an explicit key for anonymous members.
A member is { key, meta }. It pushes only when the roster actually moves — a join, a leave, a change to someone's meta, a sweep, a peer's delta, a peer's death. A heartbeat that repeats what the server already knows pushes nothing, which is what keeps a large steady room free.
meta is client-supplied, unvalidated, and handed to every channel member verbatim — identity does not belong in it. That contract is right for a cursor or a status flag, and wrong for userName / avatarUrl: any member of a channel could present any name and any <img src> to everyone else. For identity fields, give the plugin a server-side resolver — it runs on every heartbeat and its result is merged OVER the caller's meta, so a client cannot override what the server says about them:
presencePlugin({
// `store` is the app's DataStore, handed over at boot — app.config.ts is
// evaluated long before one exists, so the hook receives it rather than
// making you smuggle one in through a module cell.
resolveMember: async ({ subject, store }) => {
const rows = await store.query({
table: 'users', predicate: { column: 'id', op: 'eq', value: (subject as { id: string }).id },
order: [], projection: undefined, skip: undefined, take: 1,
} as never) as ReadonlyArray<{ name?: string; avatarUrl?: string }>
const user = rows[0]
return { userName: user?.name ?? null, avatarUrl: user?.avatarUrl ?? null }
},
// The keys the SERVER owns — stripped from the caller's meta before the merge.
identityFields: ['userName', 'avatarUrl'],
})Read the merge precisely — the obvious resolver has a hole in it. Resolved
fields are merged OVER the caller's meta, so a key the resolver does not
return is not overwritten: it keeps whatever the client sent. A resolver that
returns only what it found ({ userName } for a user with no avatar) therefore
leaves a caller-supplied avatarUrl — or a userName the resolver has never
heard of — standing in the roster every other member reads. That is the exact
substitution the hook exists to prevent, and a deployment hit it while adopting
the hook.
Two ways to close it, and declaring is the better one:
identityFields: ['userName', 'avatarUrl']— the keys the server owns. They are removed from the caller'smetaBEFORE the merge, so a key the resolver happens not to return on this call is absent rather than caller-controlled. Ignored without aresolveMember: with no server identity to protect, stripping a client field would only delete data the app put there deliberately.- Return every identity key on every call,
nullfor the ones you have no value for.
The roster key is already the subject id, so a by-id lookup is the whole job — keep it cheap or cached. Alternatively resolve identity on the READ side from the roster keys (a users.getByIds query over member.key) and keep meta for ephemeral state only.
There is a second
usePresence, and it is a different hook.@voltro/local-first/reactexports one for peer-to-peer awareness —usePresence(roomId, self, { channel })→{ presence, others, setPresence }— carrying high-frequency cursor and selection state over a pub/sub channel. This one is the server-backed roster. Different packages, different signatures; pick by the question you are asking.
Typing indicator
useTyping is a typing indicator built on the SAME presence primitive — no new transport. While isTyping, the client heartbeats into a short-TTL lane typing:<channel>; stop() / unmount leaves it, so a typer drops off within the heartbeat window.
import { useTyping } from '@voltro/plugin-presence/web'
const Composer = ({ channel, myKey }: { channel: string; myKey: string }) => {
const typing = useTyping(channel, { selfKey: myKey })
return (
<>
<textarea onFocus={typing.start} onBlur={typing.stop} />
{typing.active.length > 0 && <em>{typing.active.length} typing…</em>}
</>
)
}useTyping(channel, opts) → { active, isTyping, start, stop }: active is the other members currently typing (self excluded via selfKey), start()/stop() toggle broadcasting. A shorter heartbeat than the roster (heartbeatMs default 3s) so a typer clears quickly; the lane roster is push-driven like usePresence (no poll). activeTypers(members, selfKey) is the pure self-exclusion helper it uses.
Cost
Measured on the in-memory tracker:
a heartbeat (track), 1k members in the room |
0.10 µs |
roster, 1k members |
27 µs |
roster, 10k members |
283 µs |
| a full sweep, 10k members | 77 µs |
A heartbeat is a map write and costs nothing; reading the roster is linear in
the room and is the number to watch. 10k in one channel is 283 µs per read —
fine for a roster panel, wrong for a per-frame cursor overlay, which belongs in
an event declared delivery: 'latest' rather than in presence metadata.
Notes
- The roster is push-driven:
presence.listdeclares a reactivity channel as itssource:, so the framework re-runs it and pushes deltas over the subscription transport — no client polling. There is no table, and there is no longer a table NAME either: presence used to declare_voltro_presenceand never write to it, purely to own a name the reactivity layer would route on. If you have an existing_voltro_presence, it is empty and the upgrade does not drop it (the differ never plans a drop for a framework table no app declares) — remove it by hand when convenient. - A repeat heartbeat does not push. Measured on this machine at 2.7 µs per subscriber per publish, an unconditional push cost a steady room of N clients N² × that per heartbeat interval — ~107 ms of CPU per 15s at N=200, and a per-node ceiling around 750 subscribers that nothing in the app controlled. Publishing only on a real change removes that term entirely; what remains is linear in actual roster churn. Reproduce with
node --import tsx packages/plugin-presence/scripts/rosterFanoutBody.ts. - A member has no
lastSeen. It used to, and it was the owning replica's clock — "active 3 minutes ago" rendered from it is wrong by whatever the skew between two pods is. Usemetafor anything you need to show. - A member counts as online for
timeoutMsafter its last heartbeat. The sweep runs everytimeoutMs / 3, so a vanished member is gone within roughly 1.3× the window. - The sweep needs no cluster coordination, and that follows from the design rather than being a shortcut: every member is owned by exactly one replica and nobody else may touch it, so each replica sweeps its own and there is nothing to contend over. (The table version did need coordination — its rows were shared.)
timeoutMs and heartbeatMs are one contract
presencePlugin({ timeoutMs: 30_000 }) // server: online for 30s after a beat
usePresence(channel, { heartbeatMs: 15_000 }) // client: beats every 15sA member counts as online for timeoutMs after its last heartbeat, and the
client decides how often that heartbeat is. The server value must comfortably
outlast the client's — the shipped default pair is 30s / 15s, a factor of two.
Set it below the heartbeat and every member expires between beats: the roster
flaps empty, and nothing reports it, because an empty roster is also what
"nobody is here" looks like. A value under a second is refused at declaration
for that reason; there is no "never expire" spelling, so omit timeoutMs for
the default.
A long window is fine — a signage terminal beating once a minute is a real deployment. The rule is a floor, not a range.
Permissions
None for the roster itself — presence is in memory, so a heartbeat writes no rows.