# Show who is on a channel

Presence keeps a roster of who is currently on a channel. Your users' browsers join and leave it; your backend reads it. It is off by default and turned on per namespace.

## Turn presence on

1. **Set `presence: true` on the namespace.** It applies to every channel under that namespace. Namespaces are configured from the platform — over MCP, [`realtime_namespace_update`](/reference/mcp-tools/realtime_namespace_update).
2. **Mint tokens with the `presence` operation.** A browser needs `presence` in the channel's `ops` for every channel whose roster it will take part in. See [authentication](/ship/realtime/authentication).

> Reading or joining presence on a namespace that does not have it enabled is refused with `403`.

## Enter presence from the browser

A browser enters presence as part of subscribing to the channel:

```ts
const sub = client.subscribe('chat:room-1', onMessage, {
  presence: {
    info: { name: 'Ada', color: '#4f46e5' },
    onState: (members) => setRoster(members),
    onJoin: (member) => upsert(member),
    onLeave: (identity) => remove(identity),
  },
})
```

`info` is opaque metadata you attach to the member — a display name, an avatar, whatever your interface needs. It must serialize to no more than 10 KB.

The client resubscribes on its own after a reconnect, so the member re-enters the roster without you doing anything.

## Update and leave

Replace this connection's `info` at any time:

```ts
await client.updatePresence('chat:room-1', { name: 'Ada', typing: true })
```

The promise resolves when the service acknowledges the update. It rejects if the connection is not open, if this connection is not a presence member of the channel, or if the new `info` is too large.

A member leaves when its last connection to the channel goes away — the tab closes, the client is closed, or the network drops for long enough. There is no explicit leave call in the browser client: dropping the subscription clears local state but does not remove the member from the roster.

> Because a short reconnect should not look like a departure, a dropped connection only produces a leave once the delay has passed and the member has no connections left. A client that comes straight back stays in the roster.

## The snapshot and the deltas

Your client sees the roster through one snapshot and two deltas:

| Callback | When it fires |
|---|---|
| `onState(members)` | On the initial subscribe and again after every resubscribe, with the full roster |
| `onJoin(member)` | A member entered or its `info` changed |
| `onLeave(identity)` | A member left |

Each member carries `identity`, `connections` — how many live sockets that identity has on the channel — and the `info` it entered with.

The guarantees to build on:

- **The snapshot is authoritative.** Rebuild your local roster from every `onState` you receive rather than trusting deltas accumulated since the last one. That is what makes a reconnect self-healing: the fresh snapshot replaces anything you missed while disconnected.
- **`onJoin` is an upsert, not strictly a join.** The same identity can arrive more than once — a second tab, a changed `info`. Key your roster by `identity` and replace the entry.
- **Deltas are best effort.** They can be missed across a connection drop; the next snapshot is what puts you right. Do not treat a missing `onLeave` as proof that somebody is still there.
- **The roster is deduplicated by identity.** One person with three tabs is one member with `connections: 3`, not three members.

The client also exposes the roster it currently holds, which is convenient for rendering:

```ts
const members = client.presence('chat:room-1')
```

It is empty until the first snapshot arrives.

## Read the roster from your backend

Your backend reads the roster and the count with your product API key, on the two presence routes listed in [what your API key reaches](/ship/realtime/server-sdk#what-your-api-key-reaches). With the server SDK:

```ts
const { members } = await realtime.presence.get('chat:room-1')
const { members: count } = await realtime.presence.stats('chat:room-1')
```

Over MCP, [`realtime_presence_get`](/reference/mcp-tools/realtime_presence_get) and [`realtime_presence_stats`](/reference/mcp-tools/realtime_presence_stats).

The roster response returns one entry per identity:

```json
{
  "channel": "chat:room-1",
  "members": [
    { "identity": "user-42", "connections": 2, "info": { "name": "Ada" }, "ts": 1754130000123 }
  ]
}
```

`ts` is when that member was last updated, in milliseconds. The stats response returns `members` as a plain number — the count of distinct identities present, not the number of connections. Use it when you only need "12 people here" and do not want to transfer the roster.

> Presence writes are not part of the public route set. A backend holding a product API key reads the roster and the counts; the browser client is what enters, updates and leaves presence.

## Next steps

- [Receive channel events on your backend](/ship/realtime/webhooks): be told about members entering and leaving instead of polling the roster.
- [Connect a browser tab](/ship/realtime/browser-client): the presence options in full, alongside subscribe and reconnect.
- [Authenticate your backend and your users](/ship/realtime/authentication): mint a token carrying the `presence` operation.
- [Look up a limit or an error](/ship/realtime/limits-and-errors): roster size, `info` size and what `403` means here.
