Presence
A presence channel keeps a live roster of who is currently subscribed. You write one resolver, the client reads a typed member list, and the transport keeps it accurate across app instances.
Who is in this room right now? A subscription cannot tell you, because it only carries events. A presence channel adds the roster beside them.
Add a resolver
.presence() is only callable after .authorize(). Call it in the wrong order
and TypeScript rejects it. The resolver runs per subscriber and returns that
subscriber's member object.
export default channel("chat-room-[roomId]")
.events({ message: z.object({ text: z.string() }) })
.authorize({ subscribe: async ({ session }) => Boolean(session?.user) })
.presence(({ params, session }) => ({
id: session!.user.id,
roomId: params.roomId,
name: session!.user.name,
}));The resolver receives the same context as an authorization rule, AppContext
plus typed params. Subscribe authorization runs first. A denied subscriber
never reaches the resolver.
Presence also needs an identity. A subscriber the server cannot resolve to a principal is refused, so gate the channel behind a session.
Read the roster
Three methods appear on the client handle, and only on channels that declared
.presence(). Calling them on a plain channel is a compile error.
| Method | Shape |
|---|---|
presence(params) | Promise<readonly Member[]> |
subscribePresence(params, cb) | Returns the stop function |
presenceIter(params, options?) | Async generator of full rosters |
params is required on a channel with a [param]. A channel without one drops
that argument.
const members = await client.channels.chatRoom.presence({ roomId });
const stop = client.channels.chatRoom.subscribePresence({ roomId }, (members) =>
renderRoster(members),
);
for await (const members of client.channels.chatRoom.presenceIter(
{ roomId },
{ signal },
)) {
renderRoster(members);
}Each update is a whole roster, not a diff. Member is the return type of your
resolver, inferred, so renaming a field there breaks the render function here.
With TanStack Query, q.channels.chatRoom.presence({ roomId }) wraps
presenceIter() and keeps only the latest snapshot in the cache.
How each transport keeps it accurate
Pusher and Soketi use native provider presence. Nothing else is involved.
SSE has no provider, so QUESTPIE uses a Postgres table as a lease register. Each connection writes a row with an expiry. Every app instance reads the same table, so a roster is correct across a whole cluster with no extra infrastructure.
Two connections from one signed-in user collapse into one member. Rows are grouped by principal and the most recently updated one wins. Open two tabs and the room still shows you once.
Leaving is not symmetric:
- Graceful. The row is deleted and the roster republishes at once.
- Ungraceful. A killed tab or dropped network waits for the lease to lapse, 30 seconds by default.
SSE presence settings
These live under realtime.channelPresence and apply only to the SSE transport.
| Option | Default | What it does |
|---|---|---|
leaseMs | 30000 | Time after the last renewal before removal |
heartbeatMs | 10000 | How often one instance renews its own rows |
reconciliationMs | 1000 | Database sweep that catches a missed notice |
maxMembers | 100 | Distinct principals in one resolved channel |
maxMemberBytes | 1024 | Serialized size of one member object |
maxPrincipalIdLength | 128 | Length of a stable principal identifier |
The caps match what a managed provider enforces, so a roster behaves the same
after you switch transports. A subscriber that would push a channel past
maxMembers is rejected rather than silently dropped from the list.
Presence is a roster, not an audit trail
Members disappear when the connection does. Nothing here records that someone was present earlier. Write a row if you need to answer that later.
Next
Transports covers the choice behind all of this, the default SSE path and the managed Pusher or Soketi one.
Authorization
Subscribe and publish are two rules on one channel. This page covers what each one defaults to, what the rule receives, why a route handler can be denied its own publish, and how to cut a subscriber off mid-session.
Delivery
What a channel promises about the events it carries. Ordering per resolved channel, replay after a reconnect, an explicit gap when replay cannot reach far enough, and hard caps on payload and buffer size.