Realtime
pb.realtime lets your app subscribe to named channels and receive broadcasts the moment they happen — a todo updated by another device, a presence ping, a live counter. Your backend publishes with Realtime.broadcast (see Realtime); the iOS client subscribes, and can publish client-to-client as well. Every channel in the app rides one shared WebSocket that the SDK opens, authenticates, heartbeats and repairs for you. It ships in the base Palbe product — no extra package, and it works signed-out.
Quick example
import Palbe
// Subscribe to a channel. The handler fires for every "todo.updated"
// broadcast on "todos" — from your backend or from other clients.
let subscription = pb.realtime.channel("todos").on("todo.updated") { payload in
// payload: [String: AnyCodableValue]
if case .string(let id) = payload["id"] {
Task { await todoStore.reload(id: id) }
}
}
// later — stop receiving:
subscription.cancel()
And the matching publisher in your backend:
// In your backend (see /docs/backend/realtime):
await Realtime.broadcast("todos", "todo.updated", { id: todo.id });
The surface
public struct RealtimeNamespace: Sendable {
public func channel(_ name: String) -> RealtimeChannel
@MainActor public var status: RealtimeStatusStore { get }
}
public struct RealtimeChannel: Sendable {
public let name: String
@discardableResult
public func on(_ event: String,
_ handler: @escaping @Sendable ([String: AnyCodableValue]) -> Void) -> RealtimeSubscription
public func off(_ subscription: RealtimeSubscription)
public func send(_ event: String, _ payload: [String: AnyCodableValue])
@discardableResult
public func onStatus(_ handler: @escaping @Sendable (RealtimeChannelStatus) -> Void) -> RealtimeSubscription
// shared state
@discardableResult
public func onState(_ key: String,
_ handler: @escaping @Sendable (AnyCodableValue?) -> Void) -> RealtimeSubscription
public func stateValue(_ key: String) async -> AnyCodableValue?
public func setState(_ key: String, _ value: AnyCodableValue, life: String = "durable")
public func clearState(_ key: String)
// presence
public func presenceEnter(_ meta: [String: AnyCodableValue])
public func presenceUpdate(_ meta: [String: AnyCodableValue])
public func presenceLeave()
public func presenceOthers() async -> [AnyCodableValue]
@discardableResult
public func onPresence(_ handler: @escaping @Sendable ([AnyCodableValue]) -> Void) -> RealtimeSubscription
}
public struct RealtimeSubscription: Sendable {
public func cancel()
}
Channels
pb.realtime.channel(_ name: String) returns a lightweight RealtimeChannel handle whose name property echoes the channel name. Channel names are bare, app-defined strings — "todos", "room:42", "user:\(userId)" — whatever partitioning makes sense for your app.
Warning: Do not prefix channel names with
"realtime:". The transport adds that prefix internally on join and strips it on the way in; a name like"realtime:todos"would be a different, and wrong, channel.
A channel is joined on the socket when its first handler is added and left when its last handler is removed — the SDK refcounts topics, and you never join or leave explicitly. Creating a RealtimeChannel value by itself does nothing on the wire.
Names to avoid
The SDK and the platform use these topic shapes for their own traffic. Do not name a channel so that it collides with one:
| Shape | Used by |
|---|---|
flags:<projectRef> | Flags — Environment-wide flag changes |
flags:<projectRef>:<userId> | Flags — a per-user override changing |
messaging:device:<mdv_…> | Messaging — device-scoped delivery |
messaging:conv:<rfcGroupId> | Messaging — one conversation (the opaque base64 MLS group id, not the grp_… display id) |
debug:<ref>:<sessionId> | Debug Console — a live remote session |
<projectRef> comes from the publishable key, parsed as pb_<ref>_<scope><random> — not from the URL host, which is what an earlier version did and which rendered a locally-hosted project's namespace as "127". That ref segment is not what separates one Environment from another, so do not treat it as an identifier you can compare: read the key, not the host. See API keys.
Subscribing
@discardableResult
func on(_ event: String,
_ handler: @escaping @Sendable ([String: AnyCodableValue]) -> Void) -> RealtimeSubscription
- The handler receives the broadcast payload as
[String: AnyCodableValue]. - Registration is fire-and-forget:
onreturns synchronously and the join is dispatched on the shared socket afterwards. You may cancel the returned subscription before the join has even resolved. - Multiple handlers on the same
(channel, event)are fine — each gets its own subscription.
Warning: Dropping a
RealtimeSubscriptiondoes not auto-cancel it — the handler keeps firing. Hold the subscription (in@State, or a store property) for as long as you want events, and callcancel()to stop. Cancelling twice is a no-op.
channel.off(subscription) is equivalent to subscription.cancel() — use whichever reads better.
A typical SwiftUI lifetime pattern:
struct TodoListView: View {
@State private var subscription: RealtimeSubscription?
var body: some View {
List { /* … */ }
.onAppear {
subscription = pb.realtime.channel("todos").on("todo.updated") { payload in
// refresh the list
}
}
.onDisappear {
subscription?.cancel()
subscription = nil
}
}
}
Payloads: AnyCodableValue
Broadcast payloads are open-ended JSON, modeled as:
public enum AnyCodableValue: Codable, Sendable, Equatable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case array([AnyCodableValue])
case object([String: AnyCodableValue])
case null
}
Extract fields with pattern matching:
pb.realtime.channel("room:42").on("cursor") { payload in
guard case .int(let x) = payload["x"],
case .int(let y) = payload["y"] else { return }
moveCursor(x: x, y: y)
}
Publishing from the client
func send(_ event: String, _ payload: [String: AnyCodableValue])
send broadcasts an event to every other client subscribed to the channel:
pb.realtime.channel("room:42").send("cursor", ["x": .int(10), "y": .int(3)])
- The sender does not receive its own broadcast back — the join sets
broadcast.self = false. Update your local UI directly; the broadcast is for everyone else. - Fire-and-forget:
sendis synchronous and non-throwing; the push goes out asynchronously on the shared socket, and the channel auto-joins if it is not joined yet. sendis the client counterpart of the backend'sRealtime.broadcast— both deliver to the same channel subscribers.
Note: Client broadcasts are transient signals — cursors, typing indicators, pings. There is no delivery acknowledgment and no persistence. For data that must not be lost, write it through a backend call and let the backend broadcast the change.
Shared state
A channel can carry a small map of key/value entries that every subscriber sees. Unlike a broadcast, state is also handed to whoever joins later: a device arriving mid-session gets the whole map, then the changes.
let channel = pb.realtime.channel("room:42")
// Watch one key. Called with the current value as soon as state arrives,
// then on every change.
channel.onState("now_playing") { value in
guard case let .string(title)? = value else { return }
nowPlaying = title
}
// Read what you already have.
let current = await channel.stateValue("now_playing")
// Write it.
channel.setState("now_playing", .string("Blue Train"))
// Remove it.
channel.clearState("now_playing")
That is the difference from on(_:_:): a subscriber to an event learns nothing until the next
one fires, while state is already there when you ask.
durable vs ephemeral
channel.setState("cursor", .object(["x": .int(10), "y": .int(3)]), life: "ephemeral")
channel.setState("now_playing", .string("Blue Train")) // life defaults to "durable"
life | Who owns it | When it disappears |
|---|---|---|
"durable" (default) | the channel | when somebody clears it |
"ephemeral" | this connection | automatically, when the socket closes |
"ephemeral" is for anything describing a participant rather than the room — a cursor, a
typing flag, "who is here". A backgrounded app that loses its socket takes its entries with it;
nothing has to be un-announced on a crash.
Note: Shared state is live coordination, not storage. It does not survive a restart of the service behind the channel. Anything that must outlive the session belongs in your tables, with the channel carrying the news that it changed.
Writing state needs the state.write grant on the channel declaration, and receiving it needs
state.read — see Channels.
Presence
Presence is "who is on this channel right now", built on ephemeral state so it cleans itself up:
let channel = pb.realtime.channel("room:42")
channel.presenceEnter(["name": .string("Ada"), "color": .string("#7c3aed")])
channel.onPresence { others in
participants = others // everyone except this device
}
// Change your own metadata without leaving.
channel.presenceUpdate(["name": .string("Ada"), "typing": .bool(true)])
// Leave early — closing the socket does this on its own.
channel.presenceLeave()
| Method | Notes |
|---|---|
presenceEnter(_:) | Announce this device with arbitrary metadata. |
presenceUpdate(_:) | Replaces the announced value. A cursor moving is this, at whatever rate the app likes. |
presenceLeave() | Remove now. |
presenceOthers() async | The others' metadata, this device excluded. |
onPresence(_:) | The announced set, on every change. |
presenceOthers() and onPresence both exclude this device — you already know your own
metadata, so the result renders directly.
Channel status
A channel is in exactly one state at a time, and that state — not a separate error channel — is where you read whether it is carrying anything:
public enum RealtimeChannelStatus: Sendable, Equatable {
case idle // nobody subscribed
case joining // asked; no answer yet
case joined // the server acked it — pushes arrive here
case refused(reason: String, retryable: Bool) // the server said no
case offline // the socket is down
}
channel.onStatus { status in
switch status {
case .joining: spinner()
case .joined: ready()
case .refused(let why, let retry): retry ? showTemporaryProblem() : showNoAccess(why)
case .offline, .idle: greyOut()
}
}
retryable == false (unauthorized) means the server decided this device may not be here,
and asking again will not change it. retryable == true (unavailable, rate_limited,
too_many_channels) means the project could not be asked, and the socket's next reconnect
retries on its own. The distinction is preserved rather than flattened, so the UI can tell
"you cannot" from "not right now".
Do not infer a channel from the socket. pb.realtime.status says whether the SOCKET is
up; onStatus says whether THIS CHANNEL is carrying. They are different questions, and a
connected socket whose channels were all refused answers the first one yes and the second
one no.
onStatus hands you the CURRENT status once, as you register, and then only on changes.
Registering after a channel has already settled is therefore safe: you are told where it
stands rather than left waiting for a transition that may never come.
A refusal is not erased by letting go of the channel. If your handler unsubscribes on a
final refusal — the right thing to do, since nothing can arrive on a channel the server has
settled — the status stays .refused with its reason, because .idle would claim nobody
asked. Re-subscribing (after a sign-in, say) moves it back to .joining.
onErrorandRealtimeChannelErrorwere removed in 0.58.0. A refusal is not an error that happens to a working channel — it is one of the states a channel can be in, and the same question had to be answerable from one place.
Connection status in SwiftUI
pb.realtime.status is a @MainActor @Observable store — read it in a body to drive a live connection badge:
struct ConnectionBadge: View {
var body: some View {
let status = pb.realtime.status
Circle()
.fill(status.state == .connected ? .green : .orange)
.frame(width: 8, height: 8)
if let at = status.lastEventAt {
Text("last event \(at.formatted(date: .omitted, time: .standard))")
}
}
}
| Property | Type | Meaning |
|---|---|---|
state | RealtimeConnectionState | .idle (no socket yet), .connected (open, topics joined), .reconnecting (dropped or paused; recovering) |
lastEventAt | Date? | When the most recent broadcast on any channel arrived; nil if none yet |
RealtimeConnectionState has exactly those three cases on iOS. lastEventAt is stamped on any inbound broadcast, so a view reading it re-renders each time a push lands — which is how you prove during development that delivery is live rather than polled.
Connection behavior
All of this is automatic; it is documented so you know what to expect.
- One shared socket. Every channel — yours, plus the SDK's own flags sync and, if you link it, messaging delivery — is multiplexed over a single WebSocket. Subscribing to ten channels costs one connection. Channels themselves need nothing beyond
Palbe: encrypted chat on the same socket needsimport PalbeMessaging, and neither pulls inPalbeCallor the ~29 MB WebRTC stack it carries. - Heartbeat. A heartbeat every 25 seconds keeps idle connections alive through proxies and load balancers.
- Reconnect. A dropped socket reconnects with exponential backoff — 1s, 2s, 4s, … capped at 30s — plus a small deterministic offset derived from the connection's own counter, not from a random number generator. On reconnect, every previously joined topic is re-joined automatically, so your subscriptions survive network blips with no code.
- The attempt counter resets only on a genuine open, meaning the first frame actually arriving. A server that refuses the handshake in a millisecond therefore walks 1, 2, 4 … 30s instead of hammering once a second forever.
- Background and foreground. Backgrounding pauses the connection (
statebecomes.reconnecting) while remembering the channel set; foregrounding reconnects and re-joins everything. Broadcasts sent while the app was backgrounded are not replayed — realtime is live signal, not a message queue.
Note: Events that arrive while a channel has no registered handler are dropped, and nothing is queued while the socket is down. After subscribing — and in any handler reacting to a reconnection — fetch current state through a backend call, then use broadcasts to stay current.
Auth: it works signed-out
The Realtime server validates every connection's token against the Environment's JWKS, so the token must be a JWT. Your publishable key is not one, and presenting it would be rejected outright.
- When a user is signed in, the socket presents that session's token.
- When nobody is signed in, the SDK mints an anonymous
role=anonJWT withPOST /auth/anonymous— no body and no Bearer; the publishable key attached by the transport authorizes the mint. The token is memoised and re-minted 60 seconds before it actually expires, and concurrent callers collapse onto one in-flight mint. - The mint is cold-start resilient: on
502,503,504, a429, or a transient network error it retries up to six times, backing off from 500 ms to a cap of 8 s. Without that, a first connection against an Environment that is still starting would fail the mint. - If no JWT is available yet,
connect()defers rather than presenting the publishable key and being rejected — it schedules a reconnect and comes up clean on the next attempt. - On sign-in and sign-out the connection re-binds, so user-scoped delivery follows the session. See Auth.
There is no API to call and no flag to set for any of this.
On the wire
Palbase Realtime speaks Phoenix Channels v2 JSON over one WebSocket, on Foundation's URLSessionWebSocketTask:
wss://<ref>.palbase.studio/realtime/v1/websocket?vsn=2.0.0
The token is not in the query string — it rides the x-api-key header on the upgrade request, which the server reads before the ?apikey= fallback, so it stays out of proxy and access logs. Frames you will see in a proxy:
join [join_ref, ref, "realtime:<topic>", "phx_join", {access_token, config}]
reply [join_ref, ref, "realtime:<topic>", "phx_reply", {status, response}]
heartbeat [null, ref, "phoenix", "heartbeat", {}]
broadcast [null, null, "realtime:<topic>", "broadcast", {type, event, payload}]
The transport traces token kind, reconnect attempts, joins and inbound frames when PALBASE_DEBUG is set — see Overview for how to turn debug tracing on.
Reserved namespace
realtime is one of nine namespaces codegen refuses to emit, so a generated method can never collide with pb.realtime. The full set is auth, analytics, flags, realtime, notifications, perf, messaging, purchases and debug — see Flags.
Related
- Realtime (backend) — publishing broadcasts from your endpoints.
- Realtime (web) — the same channels from the browser.
- Flags — how the shared socket accelerates flag delivery.
- Messaging — encrypted chat, which rides the same connection.