QUESTPIE
ClientChannels

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.

View markdown

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.

src/questpie/server/channels/chat-room.ts
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.

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

OptionDefaultWhat it does
leaseMs30000Time after the last renewal before removal
heartbeatMs10000How often one instance renews its own rows
reconciliationMs1000Database sweep that catches a missed notice
maxMembers100Distinct principals in one resolved channel
maxMemberBytes1024Serialized size of one member object
maxPrincipalIdLength128Length 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.

On this page