Realtime (WebSockets and SSE)
Live channels over WebSockets or server-sent events — Postgres table changes, broadcast, presence and replay on reconnect, without a socket server to run.
Realtime is a named channel your users subscribe to over a WebSocket, or over server-sent events (SSE) when they only need to listen. Changes fan out to every connected client in milliseconds — and there is no socket server for you to run, because the connection terminates at the platform and your app only publishes.
There are two kinds of channel:
| Kind | Looks like | Who writes to it |
|---|---|---|
| Table channel | orders:insert, messages:* | Your server and your database — row changes publish automatically. End users only listen. |
| Topic | chat, chat.room-42, cursors.doc-7 | Your server always; your signed-in users too, once you turn broadcast on for the topic. |
A topic covers every channel under it — declare chat once and chat.room-42 and chat.lobby are both chat channels. Rooms are keys after the dot, not topics of their own.
Connect from the browser
A browser connects with a short-lived token for the signed-in user. The SDK asks your app for one — your app's runtime answers at POST /camplax/realtime/session using the sign-in cookie, so there is nothing to build. The stream itself is on Camplax (https://camplax.dev), not on your app's address, so it works the same on a custom domain:
import { createCamplax } from "@camplax/sdk";
const camplax = createCamplax({ baseUrl: "https://my-app.camplax.app" });
const token = await camplax.realtime.token({ channel: "chat.*", publish: true });
const room = camplax.realtime
.channel("chat.room-42", { token })
.on("broadcast:message", (payload, msg) => addMessage(msg.from, payload))
.on("presence", () => renderOnline(room.presence()))
.subscribe();
room.track({ typing: false });
await room.broadcast("message", { text: "hi" });
The token lives 15 minutes and names one channel, a whole topic (chat.*) or everything (*). publish: true asks for broadcast and presence — the topic's settings still decide whether the user gets them. Browsers cannot set headers on a WebSocket or EventSource, so the token rides the URL as access_token; a cx_ project key is refused there because it does not expire. When a token expires, its WebSocket is closed (code 4001) — broadcast and presence rights end with the token. Pass token as a function (like () => realtime.token({ … }) above) and the SDK reconnects with a fresh one; a fixed token string stops at expiry. An SSE stream is receive-only and runs until it disconnects.
For the raw protocol, the same streams are:
https://camplax.dev/v1/realtime/sse?channel=orders:insert&access_token=…
wss://camplax.dev/v1/realtime/ws?channel=chat.room-42&access_token=…
The token says which project and environment it is for, so the URL needs no slug. Any page can open the stream — it carries no cookie, only the token. If your app sets a Content-Security-Policy, add https://camplax.dev wss://camplax.dev to connect-src.
Broadcast and presence
Both are off for a new topic. Turn them on under Realtime → Topics, with the MCP realtime set_topic action, or in camplax.json:
| Setting | What it allows |
|---|---|
| Broadcast | Signed-in users send {type: "broadcast", event, payload} frames over the WebSocket (POST /v1/realtime/broadcast is for servers; a browser page on your app cannot call it). Everyone on the channel receives them stamped with the sender's user id as from. |
| Presence | Signed-in users track() a small state — online, typing, a cursor, up to 1 KB. Everyone gets presence join/update/leave events and a presence_state snapshot on connect. Two tabs are one person; they leave when the last one closes. |
| Who can join | Any signed-in user — the runtime mints tokens for anyone signed in. Only your server's tokens — the runtime route is refused, and only tokens your own server mints work. Use this for private rooms. |
| Replay | How many messages the channel keeps for reconnects (0–200, default 50). |
A user's frames are rate-limited (10 a second, bursts of 20; 8 KB each) and can never pose as a row event — they always arrive as type: "broadcast" with their from. POST /v1/realtime/broadcast has the same per-user limit on each channel, plus 120 a minute per user and 600 a minute per IP across channels; over it you get 429 with Retry-After. Table channels never accept client frames at all.
Tightening a topic reaches connections that are already open: switching broadcast or presence off stops them at once, and switching Who can join to Only your server's tokens closes every open connection whose token the runtime minted (code 4003; the SDK does not retry it).
Private rooms: mint tokens on your server
With Only your server's tokens, check membership in your own route and mint for that room alone. In a Plax app:
import { createPlaxRealtime } from "@camplax/plax";
const realtime = createPlaxRealtime();
"POST /api/rooms/:id/token": {
auth: true,
async handler(ctx) {
if (!(await isMember(ctx.user.id, ctx.params.id))) return notFound();
return realtime.token({ user: ctx.user, channel: `dm.${ctx.params.id}`, publish: true });
},
},
Nothing to configure: the signing key (CAMPLAX_REALTIME_SESSION_SECRET) is injected into every environment.
Publish from your server
await createPlaxRealtime().publish("chat.room-42", { event: "message", payload: { text: "Deploy finished" } });
Or over HTTP:
POST https://camplax.dev/v1/realtime/notify
Authorization: Bearer <CAMPLAX_PROJECT_TOKEN>
Content-Type: application/json
{ "channel": "orders:insert", "type": "row", "event": "insert", "row": { "id": 42 } }
Your app already carries CAMPLAX_PROJECT_TOKEN, which includes the realtime:publish scope — server code needs no extra key. Publish accepts either a channel message (channel plus a row or payload) or a table-change shape (table and op) that fans out to every matching channel. Another project key needs realtime:publish to publish, and realtime:subscribe (or db:read) to listen.
Keys made before realtime had scopes. Until realtime scopes shipped (September 2026), any project key could use realtime. App tokens were given realtime:publish automatically, so deployed apps keep working with nothing to do. A key you made yourself before then, with no realtime scope, keeps its old realtime access for 30 days after the release; every response to it carries Deprecation, Sunset (the exact end date) and Warning headers. Before that date, create a key with realtime:publish or realtime:subscribe under Keys, or publish from the app itself with its injected token.
Previews get their own channels
Every environment has its own realtime namespace. Rows you edit in the console's database studio (or with the MCP database tool) on a preview's database are announced on that preview's channels, never production's. A preview's app token, its runtime and the user tokens it mints all resolve to that preview's channels — a preview can publish and broadcast freely and still never reach a production subscriber, because they are different objects, not a filter on the same one. A token bound to one environment is refused if it asks for another. From the console or a project-wide key, pick a preview with ?environment=<env id>.
Table-change triggers
This is the part that makes it feel like the database is live. In the console you create a channel bound to a table and the operations you care about — insert, update, delete, or *. Enabling it installs a trigger on that table in your app's database, and from then on every row change publishes to the channel automatically. Channel names match the binding: orders:insert gets inserts, orders:* gets everything. Disable or delete the channel and the trigger comes out again.
What a client sees
On connect, a {"type":"connected","channel":"…","seq":41} hello. Then one JSON message per change:
{ "type": "row", "channel": "orders:insert", "event": "insert", "table": "orders", "row": { "id": 42 }, "at": 1736…, "seq": 42 }
Every message carries a seq. Reconnects replay what was missed: pass ?since=<last seq> (the SDK does this for you), and an EventSource resumes on its own because each SSE frame has an id:. If the channel's buffer no longer reaches back that far, a {"type":"replay_gap"} arrives first — refetch your state then. The buffer keeps the last 50 messages by default and nothing older than a day.
The stream sends a ping every 25 seconds to keep proxies quiet. If a client falls behind on the live stream it gets the latest message and the intermediate ones are counted as dropped, which the console shows per channel.
In the console
The Realtime tab lists table channels with live stats — connected clients, messages per minute, today's peak, drops, average fan-out time — and your topics with their broadcast, presence and who-can-join switches. Live now lists every channel with someone connected; pick one to see each subscriber (user id, how they connected, WebSocket or SSE, for how long), who is tracked in presence and how full the replay buffer is. Over the API: GET/POST /v1/projects/<slug>/realtime, PATCH/DELETE /realtime/:id, GET/POST /realtime/topics, DELETE /realtime/topics/:topic, and GET /realtime/inspect[?channel=…&environment=…].
Under the hood: one Durable Object per channel per environment holds the connections and the replay buffer; row changes come from Postgres triggers on your own tables.