QUESTPIE
ClientChannels

Transports

Channels run over server-sent events with no configuration at all, or over managed WebSockets with Pusher or Soketi. Switching is one edit in the runtime config and no edit at all in your application code.

View markdown

Do you need a WebSocket provider to ship channels? No. Write nothing, and channels work over SSE against your Postgres database. The provider is a choice you can make later, and it changes no call site.

The default path

With no realtime config, the client asks GET /channels/config. The answer says transport: "sse", and the client takes the SSE route.

OperationWhere it goes
SubscribePOST /realtime, the same stream as live queries
PublishPOST /channels/publish
PresenceA Postgres lease table

One stream carries every channel and every live query the page opened. Channels do not add a second connection.

Moving to Pusher or Soketi

Install the two optional peers, pusher and pusher-js. They load only from the isolated adapter entrypoint, so the root import stays clean.

src/questpie/server/questpie.config.ts
import { runtimeConfig } from "questpie/app";
import { pusherRealtime } from "questpie/adapters/pusher";

import env from "./env";

export default runtimeConfig({
	db: { url: env.DATABASE_URL },
	realtime: pusherRealtime({
		appId: env.PUSHER_APP_ID,
		key: env.PUSHER_KEY,
		secret: env.PUSHER_SECRET,
		cluster: env.PUSHER_CLUSTER,
	}),
});

pusherRealtime() returns { changeBroker, clientTransport }, which fills both realtime seams at once. For Soketi, point it at your own host with the options below.

OptionFor
clusterHosted Pusher. Defaults to mt1
host, portThe server-side HTTP API of your own Soketi
wsHost, wsPortThe browser socket, when it differs from host
wssPortThe TLS browser socket
useTLSDefaults to true
brokerChannelThe internal wake channel. Derived if you omit it

Nothing in client.channels.* changes. The client rediscovers the transport from /channels/config and the same code runs.

What reconnect does

A dropped provider socket means missed events. On reconnect the subscription reauthorizes, then drains POST /channels/replay starting from the last event id it applied. Live events that arrive mid-drain wait behind the replay cursor, then get deduplicated and released in order. Your callback never sees them out of sequence.

SSE has no separate replay call. It reopens POST /realtime carrying the last event id, and the server resumes the stream from there.

If replay cannot reach that far back, the server answers with a gap instead of inventing state. See Delivery.

The epoch fence

The moment the shared Pusher connection leaves connected, that epoch is over. Every channel riding it stops accepting provider frames, drops what it had buffered, and cancels a replay still in flight. Your onError gets Channel connection epoch ended.

The subscription itself survives. So this error is a banner, not a teardown. Resubscribing on it only doubles the work the client is already doing.

A frame that arrives while no epoch is open is dropped, not folded into the next one. That is what keeps the sequence honest across the gap. Readiness starts over with the epoch, so onReady fires again once the fresh replay drains.

SSE ends an epoch too, and it does it quietly. A retryable drop resets readiness and reconnects without touching onError. So onReady is the signal that works on both transports.

After a login or a logout

Pusher signs the browser in with an opaque user id, using the current dynamic auth headers and cookies. Channel authorization waits for that sign-in to finish and refuses a stale socket. The connection itself is shared and long-lived, so a changed identity needs it rebuilt.

client.realtime.destroy();
client.channels.destroy();

Call both, or recreate the client, before subscribing as the new user. client.channels.destroy() alone leaves the shared provider connection under the old identity.

destroy() tears down the whole channel runtime, so keep it for teardown and identity changes. A component that is unmounting should call the stop() it got from subscribe(), or abort its iterator.

Provider client events skip everything

Pusher's own client events bypass the publish route, authorization, Zod, the ordered ledger, replay and rate limits. clientEvents is off by default and needs acknowledgeProviderWideRisk: true. Use publish() instead.

Its allowedChannels list is an SDK affordance only. A raw provider client in the same browser can ignore it. Pusher enables client events for the whole provider app, not per channel.

Next

Delivery covers the guarantees both transports share, ordering, replay, gaps and the size limits.

On this page