# Presence

> Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). Works cross-instance.



---

<!-- source: en/plugins/presence.md -->
## Presence

_Ephemeral realtime presence — who's online in a channel, with heartbeat, live roster, and per-member metadata (status, cursor). 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.

<Callout type="warn">
Cross-instance presence needs a broker. With [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) every replica sees the whole room; **without one, each replica sees only the clients connected to it** — a correct roster of a fraction of the room. Nothing errors, and a single-replica staging box looks perfect, so the boot logs a warning rather than leaving you to discover it in production.
</Callout>

## Wiring

```ts
// 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

```tsx
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](/docs/data/subscriptions#reactivity-channels), 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.

> **There is a second `usePresence`, and it is a different hook.**
> [`@voltro/local-first/react`](/docs/local-first/overview#presence--awareness)
> exports 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.

```tsx
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.list` declares a [reactivity channel](/docs/data/subscriptions#reactivity-channels) as its `source:`, 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_presence` and 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. Use `meta` for anything you need to show.
- A member counts as online for `timeoutMs` after its last heartbeat. The sweep runs every `timeoutMs / 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

```ts
presencePlugin({ timeoutMs: 30_000 })          // server: online for 30s after a beat
usePresence(channel, { heartbeatMs: 15_000 })  // client: beats every 15s
```

A 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.
