Realtime
pb.realtime lets your web app receive live events over WebSocket channels — and send its own. Your backend pushes events with Realtime.broadcast; the browser subscribes to a channel by name and receives the payload the moment it lands. Every channel in a tab multiplexes over one lazily created, auto-reconnecting socket, and that socket authenticates itself — with the signed-in user's token when there is one, and with an anonymous token minted on the spot when there is not.
A channel your backend has not DECLARED cannot be joined. Subscribing is not open by name: channels.ts in the backend project publishes which names exist and who may subscribe, publish or write state, and a join that matches no declaration is refused before it reaches any handler. If a subscription silently receives nothing, check the declaration first — that is where per-channel authorization lives, including a custom authorize(ctx) for membership-based access.
Quick example
import { pb } from '@palbase/web';
const channel = pb.realtime.channel('room:42');
const subscription = channel.on('message', (payload) => {
console.log('new message:', payload); // { text: 'hi', from: 'usr_…' }
});
channel.send('message', { text: 'hi from the browser' });
// later — stop listening (the last cancel on a channel leaves it)
subscription.cancel();
There is no connection setup and no connect() to await. The shared socket is created on the first on() or send(), joins the channel, and reconnects on its own.
Channels
pb.realtime.channel(name) returns the handle for an app-defined channel. Calling it with the same name always returns the same instance:
const a = pb.realtime.channel('room:42');
const b = pb.realtime.channel('room:42');
// a === b
Channel names are bare, app-defined strings — 'room:42', 'orders', 'todos:team-7'. The web SDK does not validate them: any string you can put in a Phoenix topic works, and the same string must be used on both sides.
Warning: Do not prefix channel names with
realtime:— that prefix is added by the transport. Use the bare name on both sides:channel('room:42')in the client andRealtime.broadcast("room:42", …)in the backend. A pre-prefixed name is double-prefixed and the broadcast is silently dropped.
Warning: Realtime is client-only. Calling
pb.realtime.channel(...)on the server (SSR, React Server Components, Route Handlers, Node scripts) throwsBackendError('validation')withcode: 'realtime_unavailable'. The check is the absence of bothdocumentandwindow— Node 22 ships a globalWebSocket, so testing for that alone would let server code open real sockets. Subscribe from browser code — a client component, auseEffect, or theuseChannelhook, which never throws: it reports'unavailable'when the channel cannot be opened in the browser, and stays at its'idle'default through a server render, because its effect does not run there. Readingpb.realtime.statusis safe anywhere.
Subscribing — channel.on(event, handler)
const channel = pb.realtime.channel('todos:team-7');
const sub = channel.on('todo_completed', (payload) => {
markDone(payload.todoId as string);
});
event | The event name to filter on. A channel can carry many event types; each on() listens to exactly one. |
handler | (payload: Record<string, unknown>) => void — called with the broadcast payload. |
| Returns | RealtimeSubscription — an object with a single cancel(): void. |
Behavior details:
- Multiple
on()calls for the same event each get their own subscription; all matching handlers fire. - Joins are refcounted per channel: the channel is joined on the shared socket when its first handler is added, and left when its last subscription cancels.
cancel()is idempotent — cancelling twice is a no-op.- Subscribing is fire-and-forget: the join rides the socket asynchronously; you do not await it.
- Only frames the server marks as broadcasts reach your handler. Join replies, presence bookkeeping and heartbeat replies are dropped by the codec, so a handler never sees transport chatter.
Sending — channel.send(event, payload?)
channel.send('todo_completed', { todoId: 'todo_123' });
send(event: string, payload: Record<string, unknown> = {}): void broadcasts the payload to the channel's other subscribers. If the channel is not joined yet, send joins it first and queues the message until the join is live — you can call it on the line after channel(). Leaving a channel discards anything still queued for it.
Note: The sender does not receive its own broadcast. The client joins with
broadcast.self = false, so update your local UI directly when sending rather than waiting for the event to come back.
Connection status
pb.realtime.status is an observable snapshot of the shared connection — useful for a "live" badge:
const { state, lastEventAt } = pb.realtime.status;
const unsubscribe = pb.realtime.status.onChange(({ state, lastEventAt }) => {
setBadge(state === 'connected' ? 'live' : state);
});
| Property | Type | Meaning |
|---|---|---|
state | 'idle' | 'connected' | 'reconnecting' | 'error' | Current connection state. 'idle' until the first subscription creates the socket. |
lastEventAt | Date | null | Timestamp of the last inbound event — proof that pushes are actually arriving. |
onChange(cb) | (cb: (snapshot) => void) => Unsubscribe | Fires on state transitions and on every recorded event. |
Reading status is safe in any environment — on the server it simply reports 'idle'.
'connected' is promoted on the browser's open event, which means the HTTP-101 upgrade succeeded — not that a frame has arrived. 'error' means one channel gave up rejoining; the socket underneath may still be healthy and other channels unaffected.
One socket, and how it recovers
All channels multiplex over a single WebSocket per tab. You never manage the connection yourself:
- Lazy start — the socket is created on the first
on()orsend(). - Auto reconnect — on a drop the SDK reconnects with
min(2 ** attempt, 30)seconds of backoff — 1, 2, 4, 8, 16, 30, 30 … — plus sub-second jitter that is deterministic, derived from an internal counter rather than a random number.status.stateshows'reconnecting'while it works. - The attempt counter resets on a valid inbound frame, not on
open. A server that accepts the socket and immediately closes it keeps growing the backoff, which is the intended behaviour when an endpoint is flapping. - Heartbeat — a heartbeat frame every 25 seconds, chosen to stay under the gateway's idle-WebSocket cap.
- Re-join with a fresh token — every joined channel is re-joined after a reconnect, and the token is resolved again per join, so an access token that expired while the tab was asleep is replaced rather than replayed.
- Per-channel give-up — a channel whose join keeps failing is retried at most 3 times. After that it is dropped from the socket's topic list, its handlers are removed, and
status.stateflips to'error'. A lateron()for the same channel re-joins from scratch.
pb.realtime.destroy() closes the shared socket and drops the cached channel handles. The SDK calls it for you when the runtime is reconfigured (a codegen re-run in watch mode); call it yourself only if you need to stop a socket deliberately. The next channel() starts a fresh one.
Shared state
A channel can carry a small map of key/value entries that every subscriber sees — the current track, a document's lock holder, a game's scoreboard. Unlike a broadcast, state is also handed to whoever joins later: a client that arrives mid-session receives the whole map as a snapshot, then incremental updates.
const channel = pb.realtime.channel('room:42');
// Watch one key. Fires with the current value on join, then on every change.
channel.onState('now_playing', (value) => {
render(value as { title: string } | undefined);
});
// Read the value you already have, without waiting.
const current = channel.stateValue('now_playing');
// Write it.
channel.setState('now_playing', { title: 'Blue Train' });
// Remove it.
channel.clearState('now_playing');
ephemeral vs durable
channel.setState('cursor', { x, y }, { life: 'ephemeral' });
channel.setState('now_playing', { title }, { life: 'durable' }); // the default
life | Who owns it | When it disappears |
|---|---|---|
'durable' (default) | the channel | when somebody clears it |
'ephemeral' | the connection that wrote it | automatically, the moment that connection goes away |
'ephemeral' is what you want for anything that describes a participant rather than the
room: a cursor position, a typing indicator, "who is here". Nobody has to clean it up — a
closed tab takes its entries with it and the other subscribers are told.
Note: Shared state is live coordination, not storage. It does not survive a restart of the service behind the channel, and it is not a database — whatever must outlive the session belongs in your tables, with the channel carrying the news that it changed.
Writing state is a permission
setState and clearState only work where your channel declaration granted state.write,
and you only receive state at all where it granted state.read. See
Channels. Your backend can also write durable entries itself with
Realtime.state.set(...) — the usual shape when the value should be decided by the server
rather than asserted by a client.
Keeping up
You do not manage snapshots or sequence numbers. The SDK applies the snapshot it receives on
join, applies each update in order, and — if it ever notices it missed one — asks for a fresh
snapshot and continues from that. Your onState handler just sees values.
Presence
Presence is "who is in this channel right now", and it is built on ephemeral state, so it cleans itself up:
const channel = pb.realtime.channel('room:42');
channel.presenceEnter({ name: 'Ada', color: '#7c3aed' });
channel.onPresence((others) => {
renderAvatars(others); // everyone except you
});
// Change your own metadata without leaving.
channel.presenceUpdate({ name: 'Ada', color: '#7c3aed', typing: true });
// Leave explicitly. Closing the tab does the same thing on its own.
channel.presenceLeave();
| Method | Signature | Notes |
|---|---|---|
presenceEnter(meta) | (meta: Record<string, unknown>) => void | Announce yourself with arbitrary metadata. |
presenceUpdate(meta) | (meta: Record<string, unknown>) => void | Replace your metadata. |
presenceLeave() | () => void | Remove yourself now. |
presenceOthers() | () => unknown[] | The other participants' metadata, right now. |
onPresence(handler) | (handler: (others: unknown[]) => void) => RealtimeSubscription | Fires with the current roster, then on every change. |
presenceOthers() and the onPresence callback both exclude you — you already know your
own metadata, and excluding it means a roster you can render directly.
Presence needs the same state.read grant as any other state, plus state.write to enter.
Channel errors
A join can be refused, and the refusal has a meaning worth keeping:
channel.onError((err) => {
if (err.reason === 'unauthorized') showNoAccess(); // final — do not retry
else showTemporaryProblem(); // transient
});
unauthorized means the server decided you may not be here — retrying will not change it.
Anything else is a temporary condition, and the SDK is already retrying underneath. The
distinction is preserved rather than flattened, so your UI can tell "you cannot" from
"not right now".
Rotating the token without dropping the channel
pb.realtime.refreshToken(newAccessToken);
Hands the socket a fresher credential for the same user in place. The alternative — letting channels die at expiry and rejoining — would reap this connection's ephemeral entries (its presence, on every channel) and force a full state resync. A token getting older is not a disconnection and should not cost one.
Anonymous connections
You do not have to be signed in to subscribe. The token the socket presents is resolved in this order, on every join:
- the signed-in user's access token, when there is a session and it has not expired;
- otherwise an anonymous JWT minted with
POST /auth/anonymous— memoized, re-minted 60 seconds before it expires, and collapsed to one in-flight request when several joins race; - only if that mint throws, the bare publishable API key. Realtime rejects it — this last step exists so the join closure always returns something and the backoff loop can retry rather than crash.
So a subscription started before sign-in keeps working, and the first join after sign-in carries the user's own token.
On the wire
Useful when you are watching the Network tab:
wss://<ref>.palbase.studio/realtime/v1/websocket?apikey=<token>&vsn=2.0.0
The protocol is Phoenix Channels v2 — five-slot arrays, phx_join / phx_leave / heartbeat / broadcast. The join config the client sends is exactly { broadcast: { self: false }, presence: { key: '' }, private: false, postgres_changes: [] }; the client subscribes to no database changes and tracks no presence.
Note: The token travels as the
?apikey=query parameter because browsers cannot set headers on a WebSocket handshake. The iOS SDK sends the same value in anx-api-keyupgrade header — same server, two doors.
Pairing with your backend
The typical flow: a backend endpoint mutates data, then broadcasts; every subscribed browser updates instantly.
Backend (Realtime):
import { Controller, Post, Body, User, Realtime, Log, z } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
const CompleteTodo = z.object({ todoId: z.string() });
@Controller("/todos")
export class TodosController {
@Post("/complete")
async complete(
@Body(CompleteTodo) body: z.infer<typeof CompleteTodo>,
@User() user: UserT,
): Promise<void> {
// ... mark the todo complete in the database ...
const { error } = await Realtime.broadcast("todos:team-7", "todo_completed", {
todoId: body.todoId,
by: user.id,
});
if (error) Log.warn("broadcast failed", { code: error.code });
}
}
Realtime.broadcast(channel, event, payload?) never throws and never blocks the response: it returns a { data, error } result and the server answers 202 Accepted without a delivered count. Realtime is lossy by design — read error if you want to know a broadcast was refused (realtime_unconfigured, invalid_argument, a network failure), but do not treat a missing error as delivery.
Browser:
import { pb } from '@palbase/web';
pb.realtime.channel('todos:team-7').on('todo_completed', ({ todoId, by }) => {
markDone(todoId as string, by as string);
});
Realtime in React
For components, prefer the useChannel hook — it subscribes on mount, cancels on unmount, is StrictMode-safe, and reports 'unavailable' during SSR instead of throwing:
import { useChannel } from '@palbase/web/react';
function TeamTodos() {
const { status } = useChannel('todos:team-7', 'todo_completed', (payload) => {
markDone(payload.todoId as string);
});
return <Badge live={status === 'connected'} />;
}
See React Hooks for details.
What this API is not
pb.realtime carries broadcasts, shared state and presence. It does not stream database
changes: there is no postgres_changes subscription, and the client joins with it explicitly
turned off. When a row changes and browsers should know, your backend broadcasts it — which
also means you choose what is worth sending, rather than shipping every column to every
listener.
The method names are the SDK's own (on, send, onState, presenceEnter); there is no
subscribe(), unsubscribe(), track() or presenceState().
Warning: A separate npm package called
@palbase/realtimeexists in the same family and re-exports through@palbase/server. It is not this API: it hassubscribe()/track()/presenceState()/postgres_changes, and it dials/v1/realtime/websocket, a path this platform does not serve. If you found that surface in a search result or in autocomplete, you are not looking at@palbase/web.
API reference
| Member | Signature | Notes |
|---|---|---|
pb.realtime.channel(name) | (name: string) => RealtimeChannel | Same name → same instance. Throws realtime_unavailable off the browser. |
pb.realtime.status | RealtimeStatus | { state, lastEventAt, onChange } — safe anywhere. |
pb.realtime.destroy() | () => void | Closes the shared socket and drops the channel handles. |
channel.name | string | The bare channel name. |
channel.on(event, handler) | (event: string, handler: (payload: Record<string, unknown>) => void) => RealtimeSubscription | Joins the channel on the first handler. |
channel.send(event, payload?) | (event: string, payload?: Record<string, unknown>) => void | Queued until the join is live; never echoed to the sender. |
channel.onError(handler) | (handler: (err: RealtimeChannelError) => void) => RealtimeSubscription | err.reason === 'unauthorized' is final; anything else is transient. |
channel.onState(key, handler) | (key: string, handler: (value: unknown) => void) => RealtimeSubscription | Fires with the current value on join, then on every change. |
channel.stateValue(key) | (key: string) => unknown | The value held right now, without waiting. |
channel.setState(key, value, opts?) | (key: string, value: unknown, opts?: { life?: 'ephemeral' | 'durable' }) => void | Defaults to 'durable'. Needs the state.write grant. |
channel.clearState(key) | (key: string) => void | Removes the entry. |
channel.presenceEnter(meta) | (meta: Record<string, unknown>) => void | Ephemeral — cleaned up when the connection goes. |
channel.presenceUpdate(meta) | (meta: Record<string, unknown>) => void | Replaces your metadata. |
channel.presenceLeave() | () => void | |
channel.presenceOthers() | () => unknown[] | Everyone except you. |
channel.onPresence(handler) | (handler: (others: unknown[]) => void) => RealtimeSubscription | Current roster, then every change. |
pb.realtime.refreshToken(token) | (token: string) => void | Fresher credential in place; no rejoin, no lost presence. |
subscription.cancel() | () => void | Idempotent; the last cancel leaves the channel. |
Related
- Backend Realtime —
Realtime.broadcastandRealtime.state, the other half of every example here - Channels — declaring which channels exist and who may subscribe, publish or write state
- React Hooks —
useChannel, and the rest of the hook surface - Messaging — chat, which rides these same channels for typing, presence and delivery wakes
- Error Handling — the
BackendErrorshaperealtime_unavailablearrives in - Realtime (iOS) — the iOS client for the same channels