Palbase
Sign inGet started

Web SDK

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 and Realtime.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) throws BackendError('validation') with code: 'realtime_unavailable'. The check is the absence of both document and window — Node 22 ships a global WebSocket, so testing for that alone would let server code open real sockets. Subscribe from browser code — a client component, a useEffect, or the useChannel hook, 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. Reading pb.realtime.status is 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);
});
eventThe 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.
ReturnsRealtimeSubscription — 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);
});
PropertyTypeMeaning
state'idle' | 'connected' | 'reconnecting' | 'error'Current connection state. 'idle' until the first subscription creates the socket.
lastEventAtDate | nullTimestamp of the last inbound event — proof that pushes are actually arriving.
onChange(cb)(cb: (snapshot) => void) => UnsubscribeFires 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() or send().
  • 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.state shows '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.state flips to 'error'. A later on() 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
lifeWho owns itWhen it disappears
'durable' (default)the channelwhen somebody clears it
'ephemeral'the connection that wrote itautomatically, 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();
MethodSignatureNotes
presenceEnter(meta)(meta: Record<string, unknown>) => voidAnnounce yourself with arbitrary metadata.
presenceUpdate(meta)(meta: Record<string, unknown>) => voidReplace your metadata.
presenceLeave()() => voidRemove yourself now.
presenceOthers()() => unknown[]The other participants' metadata, right now.
onPresence(handler)(handler: (others: unknown[]) => void) => RealtimeSubscriptionFires 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:

  1. the signed-in user's access token, when there is a session and it has not expired;
  2. 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;
  3. 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 an x-api-key upgrade 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/realtime exists in the same family and re-exports through @palbase/server. It is not this API: it has subscribe() / 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

MemberSignatureNotes
pb.realtime.channel(name)(name: string) => RealtimeChannelSame name → same instance. Throws realtime_unavailable off the browser.
pb.realtime.statusRealtimeStatus{ state, lastEventAt, onChange } — safe anywhere.
pb.realtime.destroy()() => voidCloses the shared socket and drops the channel handles.
channel.namestringThe bare channel name.
channel.on(event, handler)(event: string, handler: (payload: Record<string, unknown>) => void) => RealtimeSubscriptionJoins the channel on the first handler.
channel.send(event, payload?)(event: string, payload?: Record<string, unknown>) => voidQueued until the join is live; never echoed to the sender.
channel.onError(handler)(handler: (err: RealtimeChannelError) => void) => RealtimeSubscriptionerr.reason === 'unauthorized' is final; anything else is transient.
channel.onState(key, handler)(key: string, handler: (value: unknown) => void) => RealtimeSubscriptionFires with the current value on join, then on every change.
channel.stateValue(key)(key: string) => unknownThe value held right now, without waiting.
channel.setState(key, value, opts?)(key: string, value: unknown, opts?: { life?: 'ephemeral' | 'durable' }) => voidDefaults to 'durable'. Needs the state.write grant.
channel.clearState(key)(key: string) => voidRemoves the entry.
channel.presenceEnter(meta)(meta: Record<string, unknown>) => voidEphemeral — cleaned up when the connection goes.
channel.presenceUpdate(meta)(meta: Record<string, unknown>) => voidReplaces your metadata.
channel.presenceLeave()() => void
channel.presenceOthers()() => unknown[]Everyone except you.
channel.onPresence(handler)(handler: (others: unknown[]) => void) => RealtimeSubscriptionCurrent roster, then every change.
pb.realtime.refreshToken(token)(token: string) => voidFresher credential in place; no rejoin, no lost presence.
subscription.cancel()() => voidIdempotent; the last cancel leaves the channel.
  • Backend RealtimeRealtime.broadcast and Realtime.state, the other half of every example here
  • Channels — declaring which channels exist and who may subscribe, publish or write state
  • React HooksuseChannel, and the rest of the hook surface
  • Messaging — chat, which rides these same channels for typing, presence and delivery wakes
  • Error Handling — the BackendError shape realtime_unavailable arrives in
  • Realtime (iOS) — the iOS client for the same channels