# Transports (/docs/client/channels/transports)

---
title: Transports
description: 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.
kind: guide
package: questpie
---

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.

```ts title="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.

| 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](/docs/client/channels/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.

```ts
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.

<Callout type="warn" title="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.
</Callout>

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](/docs/client/channels/delivery)** covers the guarantees both
transports share, ordering, replay, gaps and the size limits.
