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.
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.
| Operation | Where it goes |
|---|---|
| Subscribe | POST /realtime, the same stream as live queries |
| Publish | POST /channels/publish |
| Presence | A 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.
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.
| Option | For |
|---|---|
cluster | Hosted Pusher. Defaults to mt1 |
host, port | The server-side HTTP API of your own Soketi |
wsHost, wsPort | The browser socket, when it differs from host |
wssPort | The TLS browser socket |
useTLS | Defaults to true |
brokerChannel | The 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.
Raw subscriptions
client.realtime is the untyped layer under live(). Build a topic yourself when the collection name is a variable, or when you want the keyed events instead of whole snapshots.
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.