Realtime
The Realtime service pushes events from your backend to connected clients over WebSockets. It is broadcast-only: a handler fires an event onto a channel, and every client currently subscribed to that channel — through the web SDK or the iOS SDK — receives it. Use it to make a UI update the moment something changes server-side: a todo created, an order shipped, a message posted. It never throws, it never blocks your handler, and the stack acknowledges a broadcast rather than confirming a delivery.
Before any of it works, the channel has to be DECLARED. Channel names are not free text: channels.ts at your project root publishes which names exist and who may subscribe, publish or write state, and a client joining a name no declaration matches is refused before your code is ever reached. Broadcasting to an undeclared channel reaches nobody and reports nothing. Declare with ownerOnly(), publicChannel(), or a custom authorize(ctx) when membership decides — that function is the per-channel authorization hook, and it runs on your backend.
Quick example
Broadcast after the database write, so the user's other devices see the new todo immediately:
// modules/todos/dto/todo.ts
import { z } from "@palbase/backend";
export const CreateTodoBody = z.object({ title: z.string().min(1) });
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
export const TodoSchema = z.object({
id: z.string(),
title: z.string(),
user_id: z.string(),
done: z.boolean(),
});
export type TodoSchema = z.infer<typeof TodoSchema>;
// modules/todos/todos.controller.ts
import { Body, Controller, Post, Realtime, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
import { CreateTodoBody, TodoSchema } from "./dto/todo";
import { TodoService } from "./todo.service";
@Controller("/todos")
export class TodosController {
constructor(private readonly todos: TodoService) {}
@Post("")
async create(
@Body(CreateTodoBody) body: CreateTodoBody,
@User() user: UserT,
): Promise<TodoSchema> {
const todo = await this.todos.create(user.id, body.title);
// Fire-and-forget: a failed broadcast never fails the request.
await Realtime.broadcast(`todos:${user.id}`, "todo.created", { todo });
return todo;
}
}
On the client, subscribing is one line — see web Realtime and iOS Realtime for the full client API:
// web — `pb` is the generated client, from @palbase/web
declare const pb: {
realtime: {
channel(name: string): { on(event: string, cb: (payload: unknown) => void): void };
};
};
pb.realtime.channel("todos:usr_123").on("todo.created", (payload) => {
// append the todo, refresh the list, …
});
The order above — write first, broadcast second — is the canonical use. The database write is the source of truth; the broadcast is a best-effort signal layered on top. If the broadcast fails, the todo still exists and the client still gets its 200.
API
Realtime has a broadcast method and a small state surface:
import { Realtime } from "@palbase/backend";
// Three calls, and this is the whole surface. Each resolves a PalbaseResult —
// none of them throws.
await Realtime.broadcast("room:42", "message", { text: "hello" });
await Realtime.state.set("room:42", "now_playing", { title: "Blue Train" });
await Realtime.state.clear("room:42", "now_playing");
| Parameter | Type | Description |
|---|---|---|
channel | string | App-defined channel name, e.g. "room:42", "orders", `todos:${user.id}`. |
event | string | Event name subscribers filter on, e.g. "todo.created". |
payload | Record<string, unknown> (optional) | JSON-serialisable event body delivered to subscribers. |
Realtime is one of the ambient platform services — imported, never injected, and there is no ctx to thread through. Import it from @palbase/backend and call it from a controller method, from a service that method calls, from a job, or from a webhook or hook handler; all of them run inside the same scope. See the Overview for the full list.
Writing shared state
A broadcast reaches whoever is listening at that instant. A channel's shared state is also handed to whoever joins afterwards — so "this value is now X" belongs in state, and "X just happened" belongs in a broadcast. A client connecting a second later should not have to wait for the next change to learn X.
import { Controller, Post, Body, Realtime, z } from "@palbase/backend";
export const SetTrack = z.object({ roomId: z.string(), title: z.string() });
export type SetTrack = z.infer<typeof SetTrack>;
@Controller("/rooms")
export class RoomsController {
@Post("/track")
async setTrack(@Body(SetTrack) body: SetTrack): Promise<void> {
// Everyone in the room sees it now, and so does whoever joins next.
await Realtime.state.set(`room:${body.roomId}`, "now_playing", { title: body.title });
}
}
Realtime.state.clear(topic, key) removes an entry. Entries written this way are durable:
they belong to the channel rather than to a connection, so they outlive whoever wrote them.
(Clients can also write ephemeral entries, which are reaped when their connection goes —
that is what presence is built on. See Realtime on the web.)
Writing state from the backend is the right shape whenever the value should be decided by
the server rather than asserted by a client — a score, a lock holder, an auction's high bid.
Pair it with a channel handler when clients need to request the change: see
Channels.
Note: Shared state is live coordination, not storage. It does not survive a restart of the service behind the channel, and the source of truth for anything that must outlive the session is your own tables.
Fire-and-forget semantics
broadcast resolves a PalbaseResult<void> instead of throwing:
interface PalbaseResult<T> {
data: T | null;
error: { message?: string; code?: string } | null;
status?: number;
}
- On success it resolves
{ data: undefined, error: null }. The stack answers 202 Accepted and returns no delivered count, so success means the broadcast was accepted — never that any particular client received it. - On failure it resolves
{ data: null, error }. A failed broadcast never throws and never fails your handler: the write your handler performed still succeeded and the response is still returned. That is true of a network failure too, which this client envelopes rather than raising.
import { Log, Realtime } from "@palbase/backend";
declare const text: string;
const { error } = await Realtime.broadcast("room:42", "message", { text });
if (error) {
Log.warn("broadcast failed", error.code, error.message);
}
Note: Treat broadcasts as ephemeral signals, not durable delivery. Only clients subscribed at the moment of the broadcast receive the event — a client that connects a second later sees nothing, and there is no replay. Persist the state with Database or Documents and use the broadcast as a cue to update or refetch.
When a broadcast fails
Since nothing throws, error.code is the whole diagnostic:
error.code | status | What happened |
|---|---|---|
invalid_argument | 400 | channel or event was empty or not a string. Refused locally, before any request. |
realtime_unconfigured | 503 | This stack has no realtime ingestion secret, so no token can be minted. |
realtime_token_error | 500 | The token could not be signed. |
network_error | 0 | The request never reached the stack. |
realtime_broadcast_failed | the upstream status | The stack answered something other than 200 or 202; the message carries the status and the first 200 characters of the body. |
Warning: The
realtime_unconfiguredmessage the SDK produces names the environment variablePALBASE_REALTIME_API_JWT_SECRET. That name is wrong. The variable the engine reads — and the one palsvc reads, the one--init-envgenerates, the one both compose files set, and the one the egress fence scrubs — isREALTIME_INGESTION_SECRET. The old name survives only in the retired v1 orchestrator. If you are configuring a stack yourself, setREALTIME_INGESTION_SECRET; on the Palbase cloud it is set for you.
Channel naming
Channels are app-defined, plain strings. There is no registry and no setup step — a channel exists the moment someone broadcasts or subscribes to it.
| Pattern | Example | Use |
|---|---|---|
| Global | "announcements" | every subscriber sees every event |
| Per-entity | "room:42" | one channel per chat room, document or game |
| Per-user | `todos:${user.id}` | fan out to one user's devices |
Warning: Pass the bare channel name. Realtime prepends
realtime:itself on delivery, so a channel you prefix yourself is double-prefixed and the message is silently dropped — no error, no envelope, no delivery, and nothing in any log to notice. Write"room:42", never"realtime:room:42".
Note: Channels named
flags:<ref>are Palbase-internal — they carry live feature-flag sync. Author channels for your own features only.
The backend never subscribes
There is no subscribe() on the backend, and that is deliberate rather than missing. A request handler is stateless: it runs, returns, and is gone, so it has no honest place to hold a WebSocket open. The backend writes — a broadcast, or a state entry — and subscribing lives where the long-lived connection lives, in the client SDKs:
- Web Realtime —
pb.realtime.channel(…)in the browser - iOS Realtime — channels over a shared WebSocket in Swift
- React Hooks — component-scoped subscriptions
A typical end-to-end flow for the todo app:
- The web app and the iOS app both subscribe to
todos:<userId>. - The user creates a todo on the web. The controller inserts the row, then broadcasts
todo.createdontodos:<userId>. - Both apps receive the event and apply the payload, or refetch.
Note: A client does not receive its own broadcast — the SDKs join with
broadcast: { self: false }, so a browser that both sends and listens on a channel never sees its own event echoed back. A broadcast from your backend is a different sender, so every subscriber gets it, including the browser whose request triggered it. That is one reason to broadcast from the handler rather than from the client that called it.
Broadcasting from services
You do not need a request object — Realtime is ambient, so ordinary service code can use it too:
// modules/todos/todo.service.ts
import { Database, Injectable, Realtime } from "@palbase/backend";
import { TodoSchema } from "./dto/todo";
@Injectable()
export class TodoService {
/** Ownership is written HERE — the request body may not carry it. */
create(userId: string, title: string): Promise<TodoSchema> {
return Database.public.todos.insert({ user_id: userId, title });
}
async complete(id: string, userId: string): Promise<TodoSchema | null> {
const todo = await Database.public.todos.update({ where: { id }, set: { done: true } });
if (todo) {
await Realtime.broadcast(`todos:${userId}`, "todo.completed", { id });
}
return todo;
}
}
Keep the broadcast after the write succeeds, so subscribers never hear about state that does not exist. The ambient services resolve out of the current request's scope, so this works anywhere your code is reached from a handler, a job or a hook — and throws by name if you call it at module load, outside any scope.
What goes over the wire
Useful when you are debugging a delivery that did not arrive. The client posts to the stack's realtime ingestion endpoint with a token it mints in-process:
POST <module base>/realtime/api/broadcast
Authorization: Bearer <HS256 JWT, 60-second life>
Content-Type: application/json
{"messages":[{"topic":"room:42","event":"message","payload":{},"private":false}]}
The token is signed with the stack's realtime ingestion secret and claims iss: "backend-runtime", role: "service_role". The endpoint answers 202 and returns no count. Malformed entries inside a batch are skipped and the batch's good messages still deliver, so a partial failure is silent by design — realtime is lossy, and the backend never blocks on delivery.
Testing
@palbase/backend/test exports api, createTestApi and TestApiError, and that is the whole surface. Those drive a deployed candidate over HTTP, so a handler that broadcasts is exercised against a real stack rather than against a mock.
Warning: There is no
createTestContextexported from@palbase/backend/test, and there is no in-process module-client mock to override. Older documentation described one, along with aRealtime.broadcastno-op you could assert on. It does not exist. If you need to assert on what was broadcast, assert on the effect a subscriber would see, or on the row your handler wrote before broadcasting. See Testing.
Related
-
Channels — declaring which channels exist and who may subscribe, publish or write state
-
Web Realtime — subscribing from the browser
-
iOS Realtime — subscribing from Swift
-
Database — the writes you will usually broadcast about
-
Flags — flag changes ride internal realtime channels
-
Testing — what
@palbase/backend/testactually ships -
Overview — the ambient platform services