Realtime updates
realtimePush live updates to browsers over server-sent events, with every subscribe checked per channel and tenant.
realtimeFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
realtime.publish() pushes live updates from your server to connected clients, and realtime.subscribe() lets a client listen on a channel, checked per user and tenant.
events.broadcast() is a different thing. It sends a message between instances of your own app, for cache invalidation, and lives in Events and background jobs.
At a glance
realtime:
driver: broadcast
path: /mainsdk/realtime
transports: [sse, websocket]
channels:
order: { action: orders.read }
chat: { action: chats.read, send: chats.write }
replay: { size: 500, ttl: 5m }
max-event: 32KB
streams:
call: { types: ["audio/opus"], max-per-socket: 4, budget: 64MB }
socket: { rate: 50/s, buffer: 1MB }import { realtime } from "mainsdk";
await realtime.publish(`order:${order.id}`, "status", { status: "shipped" });app/orders/actions.test.ts
import { createTestApp } from "@mainsdk/testing";
const cookie = ""; // the session cookie of the person who subscribes
const app = createTestApp({ main: "./main.yaml" });
const res = await app.realtime.handler()(new Request("http://app.test/mainsdk/realtime?c=user:me", { headers: { cookie } }));
await app.tenancy.anonymous(() => app.realtime.publish("user:u_1", "ping", { n: 1 }));Calls
realtime.publish(channel, event, data)realtime.pipe(channel, tokens, { event? })realtime.handler()realtime.on(prefix, event, opts, fn)realtime.onStream(prefix, fn)realtime.ticket()realtime.subscribe(channels, { onEvent, onReset? })realtime.tokens(sub, channel, { onText, onDone })http.websocket(url, options)new SocketServer(hub)new Realtime(ports)parseRealtime(raw)publishProgress(hub, source)publishRecords(hub, source)new Broadcaster(opts)new MemoryBus()redisBroadcast(o)events(object: broadcast, onBroadcast)
Drivers
5 drivers. Select one to see its line in main.yaml.
realtime:
driver: broadcastChange this one line to swap providers; your calls stay the same.Instance broadcast integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-REALTIME-0003 | Checkmainsdk check, before deploy | A channel prefix not declared under realtime.channels (404 on subscribe, a throw on publish; a check on a literal in code) |
MAIN-REALTIME-0006 | Checkmainsdk check, before deploy | The local driver in production without single-instance: true |
MAIN-REALTIME-0007 | Checkmainsdk check, before deploy | max-event plus the 4KB envelope is larger than events.broadcast.max-payload |
MAIN-REALTIME-0012 | Checkmainsdk check, before deploy | A client message or stream refused: no send action on the prefix, access denied, or no handler |
MAIN-REALTIME-0015 | Checkmainsdk check, before deploy | realtime.transports lists websocket on an adapter or platform without HTTP upgrade |
MAIN-REALTIME-0016 | Checkmainsdk check, before deploy | A binary stream refused: prefix not under realtime.streams, type not listed, or max-per-socket reached |
MAIN-REALTIME-0001 | RuntimeError responses and logs | Subscribe with no session (401) |
MAIN-REALTIME-0002 | RuntimeError responses and logs | Subscribe refused: access denied, or a user: channel that isn't the caller's (403) |
MAIN-REALTIME-0004 | RuntimeError responses and logs | Event data is not JSON, or is larger than max-event |
MAIN-REALTIME-0005 | RuntimeError responses and logs | A channel or event name doesn't match its pattern |
MAIN-REALTIME-0008 | RuntimeError responses and logs | A stream was closed because its reader fell more than buffer behind (logged) |
MAIN-REALTIME-0009 | RuntimeError responses and logs | The person already has per-user open streams (429) |
MAIN-REALTIME-0010 | RuntimeError responses and logs | WebSocket upgrade refused: the Origin is neither the app's own nor in inbound.cors.origins (403) |
MAIN-REALTIME-0011 | RuntimeError responses and logs | WebSocket upgrade refused: the ticket is unknown, expired, used or from another tenant (401) |
MAIN-REALTIME-0013 | RuntimeError responses and logs | A client message isn't JSON, has an unknown type, is over max-message or fails the handler's schema |
MAIN-REALTIME-0014 | RuntimeError responses and logs | Client messages over realtime.socket.rate |
MAIN-REALTIME-0017 | RuntimeError responses and logs | A stream passed its byte budget, or a frame passed max-frame; the stream or socket is closed |
MAIN-REALTIME-0018 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| realtime@1 contract and channel names | Phase 1 | 0.3 | realtime#contract |
| Publish and the SSE endpoint on the local driver | Phase 1 | 0.3 | realtime#sse-local |
| Subscribe checked per channel prefix | Phase 1 | 0.3 | realtime#subscribe-auth |
| Channels keyed by tenant | Phase 1 | 0.3 | realtime#tenant |
| Replay buffer and resume | Phase 1 | 0.3 | realtime#resume |
| Back-pressure and per-user connection cap | Phase 1 | 0.3 | realtime#backpressure |
| Fan-out across instances | Phase 1 | 0.3 | realtime#fanout |
| Model token streams with pipe | Phase 1 | 0.3 | realtime#ai-pipe |
| Workflow progress and record-change events | Phase 1 | 0.3 | realtime#progress-records |
| Reconnect hint on shutdown | Phase 1 | 0.3 | realtime#shutdown |
| Browser client | Phase 1 | 0.3 | realtime#client |
| Checks for undeclared channels and oversized events | Phase 1 | 0.3 | realtime#checks |
| RFC 6455 WebSocket server on node:http | Phase 1 | 0.4 | realtime-sockets#rfc6455 |
| Upgrade checked by origin | Phase 1 | 0.4 | realtime-sockets#upgrade-auth |
| Subscribe | Phase 1 | 0.4 | realtime-sockets#socket-channels |
| Client messages handled with realtime.on | Phase 1 | 0.4 | realtime-sockets#client-messages |
| Numbered binary streams with realtime.onStream | Phase 1 | 0.4 | realtime-sockets#binary-streams |
| Bounded socket and stream queues | Phase 1 | 0.4 | realtime-sockets#backpressure |
| Outbound http.websocket with the rules of http.fetch | Phase 1 | 0.4 | realtime-sockets#outbound-parity |
| Close every socket with a reconnect code on drain | Phase 1 | 0.4 | realtime-sockets#drain |
| Socket transport in the browser client | Phase 1 | 0.4 | realtime-sockets#client |
| Checks for sockets without upgrade and undeclared send and stream prefixes | Phase 1 | 0.4 | realtime-sockets#checks |
| Real-time collaboration and presence | Phase 5 | 1.6 | platforms#collab-presence |
Quick start
realtime:
driver: broadcast
channels:
order: { action: orders.read }"use server";
import { realtime } from "mainsdk";
export async function markShipped(orderId: string) {
// update the order in db first
await realtime.publish(`order:${orderId}`, "status", { status: "shipped" });
}Configuration
realtime:
driver: broadcast # local | broadcast; pusher, ably and supabase-realtime later
single-instance: false # true says local is enough in production
path: /mainsdk/realtime
channels: # the channel prefixes clients may use, and the access action each needs
order: { action: orders.read }
chat: { action: chats.read, send: chats.write } # send: what a client message needs (websocket)
workflow: { action: workflows.read } # a run's state changes are published here
records: [orders] # models that publish `changed` to order:<id> after commit
per-user: 10 # open streams and sockets per person
transports: [sse, websocket] # websocket is opt-in
streams:
call: { types: ["audio/opus"], max-per-socket: 4, budget: 64MB }
socket: { max-message: 64KB, max-frame: 256KB, rate: 50/s, high-water: 256KB, buffer: 1MB, inbound-buffer: 1MB, ping: 20s }
ticket-ttl: 30s
heartbeat: 15s
max-age: 15m # a stream closes with a reconnect hint, so access is checked again
replay: { size: 500, ttl: 5m }
buffer: 256KB # a reader that falls further behind is disconnected
max-event: 32KB| Key | What it does |
|---|---|
driver | local serves one instance. broadcast fans events out to every instance through events.broadcast |
channels | Channel prefixes you allow, such as order for order:42, with the access action a subscribe needs and, for sockets, the send action. A plain list of prefixes reads as <prefix>.read. tenant and user:<id> are always there |
records | Models whose committed writes publish changed with { op, id, version } and no field values |
transports | sse (default) and websocket (opt-in). mainsdk check reports MAIN-REALTIME-0015 for websocket on a platform with no HTTP upgrade |
streams | Prefixes that may open binary streams, with their content types, max-per-socket and byte budget |
socket | Limits of one socket: message and frame sizes, rate of client messages, high-water per stream, buffer per socket, inbound-buffer and ping |
replay | Events kept per channel so a client can resume from Last-Event-ID |
max-event | The largest event. A bigger one fails with MAIN-REALTIME-0004 |
Channel names always live inside the current tenant. user:me is one person, and tenant is every member.
What you get
- Live updates for order status that changes without a refresh, live dashboards or a notification badge.
- Server-sent events (0.3, spec
realtime): one stream per browser carries many channels, resumes after a drop and closes when access is gone. realtime.publish(channel, event, data)sends from your server, andrealtime.subscribe(channels, options)listens in the browser.- Add
websockettotransports(0.4, specrealtime-sockets) and the same channels also run over one WebSocket, with messages from the client and binary streams. - Every subscribe is checked through the session and
access, so a client only hears the channels it may see.
The endpoint and the browser client
realtime.handler() returns the SSE endpoint as a (req) => Response handler. Adapters mount it at realtime.path.
import { realtime } from "mainsdk";
export const GET = realtime.handler();In the browser, realtime.subscribe(channels, options) opens one stream for many channels. onReset fires when events were missed, so you refetch.
import { realtime } from "mainsdk/realtime/client";
const sub = realtime.subscribe(["order:42", "user:me"], {
onEvent: (e) => console.log(e.channel, e.event, e.data),
onReset: () => void fetch("/api/orders/42"),
});
sub.add("workflow:wr_01J");
sub.close();Stream model tokens
realtime.pipe(channel, tokens) sends a model's tokens as indexed token events and a final done event with the full text. It takes the iterable ai.stream returns, unchanged. The browser reads them with realtime.tokens(), which waits for done and uses its text when an index was missed.
import { ai, realtime } from "mainsdk";
export async function POST(req: Request) {
const { chatId, prompt } = (await req.json()) as { chatId: string; prompt: string };
const { text } = await realtime.pipe(`chat:${chatId}`, ai.stream({ model: "anthropic/claude-sonnet-4-5", feature: "chat", prompt }));
return Response.json({ text });
}import { realtime } from "mainsdk/realtime/client";
const sub = realtime.subscribe(["chat:c_7"], { onEvent: () => {} });
realtime.tokens(sub, "chat:c_7", { onText: (soFar) => console.log(soFar), onDone: (text) => console.log(text) });EventSource hears events by name, so subscribe listens for a default set of names (updated, changed, status, token, done and the workflow states). Pass events: ["my-event"] for others.
Workflow progress and record changes
With realtime.channels.workflow declared, a run publishes { state, currentStep, wakeAt? } to workflow:<runId>: sleeping, running, completed, never a step result. publishProgress(hub, source) and publishRecords(hub, source) connect those two feeds to a hub through small ports, so the workflow runner and the database tell realtime when a run changes state or a transaction commits.
WebSocket
Set transports: [sse, websocket] and mount the upgrade next to the SSE endpoint. The server side is RFC 6455 on node:http and node:crypto, with no dependency and no compression.
import { createServer } from "node:http";
import { app } from "mainsdk";
import { SocketServer } from "mainsdk/realtime";
const server = createServer();
const sockets = new SocketServer(app().realtime);
server.on("upgrade", (req, socket, head) => void sockets.upgrade(req, socket, head));Every upgrade is checked before 101: draining (503), the Origin against the app's own and inbound.cors.origins (MAIN-REALTIME-0010), then a session or a single-use ticket from realtime.ticket() for clients with no cookies (MAIN-REALTIME-0001, MAIN-REALTIME-0011). Channels pass the same checks as SSE, and per-user counts sockets and streams together.
import { realtime } from "mainsdk";
import { s } from "mainsdk/schema";
import type { BinaryStream, ClientMessage } from "mainsdk/realtime";
declare function saveMessage(channel: string, user: string, data: unknown): Promise<void>;
declare function transcode(chunk: Uint8Array): Uint8Array;
// A client message needs `send` on the channel's prefix and passes access; the handler runs in the sender's tenant.
realtime.on("chat", "message", { schema: s.object({ text: s.string() }) }, async (msg: ClientMessage) => {
await saveMessage(msg.channel, msg.user, msg.data);
return { ok: true }; // sent back as the acknowledgement
});
// A binary stream, such as microphone audio in and speech out.
realtime.onStream("call", async (stream: BinaryStream) => {
const out = stream.writable.getWriter();
for await (const chunk of stream.readable) {
await out.ready; // waits at high-water instead of buffering
await out.write(transcode(chunk));
}
});Client messages are rate-limited per socket through limits (MAIN-REALTIME-0014); when the limits store is down the socket counts locally and never refuses on that account. A socket whose queue passes buffer is closed with 4008; one whose handler stops reading is paused at inbound-buffer and closed after 30 seconds.
In the browser, transport: "auto" tries a WebSocket and falls back to SSE:
import { realtime } from "mainsdk/realtime/client";
const sub = realtime.subscribe(["chat:c_7"], { transport: "auto", onEvent: (e) => console.log(e.data) });
await sub.send("chat:c_7", "message", { text: "hi" }); // resolves with the handler's reply
const mic = sub.stream("call:k_1", { contentType: "audio/opus" });send and stream reject on SSE with a clear error.
Outbound sockets
http.websocket(url, { protocols?, headers?, connectTimeout?, highWater?, maxMessage? }) opens a socket to a provider with the rules of http.fetch: the host must be in network.allow (MAIN-NET-0002), a redirect is refused (MAIN-NET-0003), the connect timeout applies (MAIN-NET-0009) and each attempt is a decision on the network diagnostics channel. send waits while the socket's buffered bytes are over highWater, and iterating the socket yields its messages.
import { http, secrets } from "mainsdk";
const ws = await http.websocket("wss://api.elevenlabs.io/v1/text-to-speech/x/stream-input", {
headers: { "xi-api-key": await secrets.get("ELEVENLABS_KEY") },
});
await ws.send(JSON.stringify({ text: "Hello" }));
for await (const msg of ws) console.log(msg);http.websocket(url, protocols?) with no options still returns the browser WebSocket after the same host check. mainsdk discover lists each site with via: websocket.
Examples
Listen for order updates in the browser
import { realtime } from "mainsdk/realtime/client";
// Call it from your component's effect (in React, `useEffect`) and return the stop function as its cleanup.
export function watchStatus(
orderId: string,
setStatus: (status: string) => void,
refetch: (orderId: string) => Promise<string>,
): () => void {
const sub = realtime.subscribe([`order:${orderId}`], {
onEvent: (e) => setStatus((e.data as { status: string }).status),
onReset: () => void refetch(orderId).then(setStatus),
});
return () => sub.close();
}Publish from a webhook
import { realtime, webhooks } from "mainsdk";
export const POST = webhooks.receive("standard", async (event) => {
const { orderId, status } = (event as { data: { orderId: string; status: string } }).data;
await realtime.publish(`order:${orderId}`, "status", { status });
});The handler runs only after the webhook signature and timestamp are checked; see Webhooks.
Update a team dashboard from a job
import { realtime } from "mainsdk";
export async function publishDailyTotals(totals: { orders: number }) {
await realtime.publish(`tenant`, "dashboard", totals);
}Reference
The broadcast driver sends each event between instances of your app through the broadcast layer of events. These are the parts it is built from.
| Name | What it is |
|---|---|
events(...) | The events object, whose broadcast(topic, payload) and onBroadcast(topic, fn) calls carry messages between instances. The catalog lists it as events(object: broadcast, onBroadcast). |
new Realtime(ports) | The hub behind realtime: channels, publishing, replay and the SSE endpoint, over the ports the app supplies. The app builds it for you; tests and adapters build their own. |
parseRealtime(raw) | Read the realtime section of a parsed main.yaml. Absent keys take their defaults, and a bad value throws with the key named. |
new Broadcaster(opts) | The class behind those two calls, over one driver. broadcast resolves with { id, reached, dropped } once the driver accepts the message or 250 ms pass, and never throws for an unreachable driver. onBroadcast returns a function that stops its handler, and close() ends the subscription. Topics are lowercase letters, digits, dots, dashes and underscores, up to 128 characters. |
new MemoryBus() | A bus that several instances in one process share. It backs the local driver in tests and mainsdk dev, and bus.sent lists the messages sent. |
redisBroadcast(o) | The redis driver: one PUBLISH per message and one SUBSCRIBE per instance on the channel <prefix>:broadcast, over the SDK's own client. Its host is a driver host of the network layer. |
Broadcast is best effort and at most once. A message is not stored or retried.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-REALTIME-0001 | Subscribe with no session (401) | Sign the person in |
MAIN-REALTIME-0002 | Access denied, or a user: channel that isn't the caller's (403) | Subscribe to a channel the person may read |
MAIN-REALTIME-0003 | A channel prefix not under realtime.channels | Declare it |
MAIN-REALTIME-0004 | Event data is not JSON, or over max-event | Send JSON under the limit |
MAIN-REALTIME-0005 | A channel or event name does not match its pattern | Use <prefix>:<id> and a lowercase event name |
MAIN-REALTIME-0006 | The local driver in production (check, runtime) | Use broadcast, or set single-instance: true |
MAIN-REALTIME-0007 | max-event plus 4KB is over events.broadcast.max-payload (check) | Lower max-event or raise the payload limit |
MAIN-REALTIME-0008 | A stream or socket was closed because its reader fell behind (logged) | The client resumes with its last id |
MAIN-REALTIME-0009 | The person already has the most open streams (429) | Close one |
MAIN-REALTIME-0010 | Upgrade refused: the Origin is not allowed (403) | Add it to inbound.cors.origins |
MAIN-REALTIME-0011 | Upgrade refused: the ticket is unknown, expired, used or from another tenant (401) | Ask realtime.ticket() again |
MAIN-REALTIME-0012 | A client message or stream refused: no send action, access denies it, or no handler (also a check) | Declare send and register realtime.on |
MAIN-REALTIME-0013 | A client message is not valid JSON, is too big or fails the handler's schema | Fix the message |
MAIN-REALTIME-0014 | Client messages over socket.rate | Wait retryAfter seconds |
MAIN-REALTIME-0015 | websocket listed on a platform with no HTTP upgrade (check) | Remove it or change platform |
MAIN-REALTIME-0016 | A binary stream refused: prefix, type or count (also a check) | Declare it under realtime.streams |
MAIN-REALTIME-0017 | A stream passed its byte budget, or a frame passed max-frame | Raise the limit or send less |
MAIN-REALTIME-0018 | Subscribe or upgrade while the app is draining (503) | Reconnect in a few seconds |
See check codes for the rest.
Swapping drivers
realtime:
driver: local # was broadcast; one instance onlylocal in production needs single-instance: true, or mainsdk check fails with MAIN-REALTIME-0006. Hosted drivers (Pusher, Ably, Supabase Realtime) come later. Your realtime.publish() calls stay the same. See Swap a driver.
Testing
realtime runs on the local driver in tests with no provider account, and app.realtime.handler() takes plain Request objects, so a test opens a stream, publishes and reads the events with no network. Time comes from the fake clock: advance it 45 seconds to see three heartbeats, or past max-age to see the reconnect.
import { createTestApp } from "@mainsdk/testing";
const cookie = ""; // the session cookie of the person who subscribes
const app = createTestApp({ main: "./main.yaml" });
const res = await app.realtime.handler()(new Request("http://app.test/mainsdk/realtime?c=user:me", { headers: { cookie } }));
await app.tenancy.anonymous(() => app.realtime.publish("user:u_1", "ping", { n: 1 }));For WebSocket, drive SocketServer.upgrade(req, socket) with a pair of in-memory duplex sockets.
Security and privacy
- Every subscribe is checked through the session and
access, per declared channel prefix and the current tenant. - Keep personal data out of messages. Send IDs and statuses, and let the client fetch details through scoped
dbreads. - A new realtime update can go to a small share of users first and pause itself on spikes in errors, through the same staged rollout as notifications.
FAQ
Is this for chat and presence? Publish and subscribe ship first. Real-time collaboration and presence come in phase 5.
Can a client publish? Over SSE, no: clients subscribe and your server publishes. Over a WebSocket a client sends messages to a channel whose prefix declares send, and your realtime.on handler decides what to publish.
Next
Was this page helpful?