Skip to content

Realtime

Realtime makes everyone see the same thing at the same time. The underlying live capability gives you one WebSocket with three things — shared rooms with presence, broadcast messaging, and live data — enough for chat, multiplayer cursors, a collaborative board, or a list that updates itself. It’s framed as outcomes, not a raw channel: you pick what should be live, not how to plumb a socket.

Your app never opens that socket. It joins through the live import, which holds the connection, reconnects with a growing backoff, keepalives, and shares one socket per room however many components join it:

import { joinRoom, watchCollection, applyChange } from "live";

One socket targets one space, chosen by what you join: a room, or a collection.

joinRoom(name, handlers) puts you in a shared room. Everyone in it sees who else is present, and anyone can publish an event to the rest:

const room = joinRoom("lobby", {
onOpen: (me) => { who = me; }, // { id, name } — who the server says you are
onPresence: (members, count) => { here = members; total = count; },
onMessage: (event, from) => append(event, from.name), // a broadcast from someone
onClose: () => {}, // dropped; a reconnect is already scheduled
onError: (err) => console.warn(err.message), // one frame of yours was refused
});
room.publish({ text: "hi 👋" }); // everyone in the room
room.sendTo("usr_7", { text: "just you" }); // one member alone
room.setPresence({ cursor: [12, 40], status: "typing" }); // your own state, re-broadcast
room.leave(); // the socket closes when the last handle leaves

Every handler is optional. onOpen fires once the server has settled who you are — and again after every reconnect, which is where a resync belongs.

On the handle What it is
room.members / room.count the latest roster ([{ id, name, meta }]) and the true total
room.me { id, name } the server settled on, or null while it is reconnecting
room.connected false while it is reconnecting — render that, not an endless spinner
room.publish(event) / room.sendTo(id, event) broadcast, or send to one member
room.setPresence(state) your own shown state, re-sent for you after a reconnect
room.leave() stop listening

publish() and sendTo() answer false if the socket is down at that instant, and nothing is queued: presence is state, so it is re-sent for you; a broadcast is an event, so it is not. members lists at most the first 100 people, count is always the true total.

The room name picks the audience, exactly the way a file key does:

Room name Who can join
public/… anyone, signed in or not
anything else members only — a signed-out visitor is refused once sign-in is on

A signed-out visitor reaches a public/ room and no other, and reaches a collection’s live feed only where that collection sets anonymous. Name the room for the audience you actually mean and turning sign-in on later just works. public on its own is the prefix, not a room — joinRoom("public") throws a LiveError rather than opening anything. public/lobby and lobby are different rooms: one audience never leaks into the other.

id and name on a member are the platform’s. When someone is signed in, both come from their session and no frame can change either — so id is who they are, and name is safe to print. A signed-out member has id: null, and then name is merely what they typed: pass one with joinRoom(room, { as: "Ada" }), which the platform ignores for anyone signed in.

A display name belongs to the account, so it is set once and is the same in every room: sign-up requires one, and /account changes it. An account a magic link created shows name: null until its owner answers on /account — render that case.

Names are not unique — key off id. Everything in meta is that member’s own claim, the last setPresence() they sent: render it, never authorise off it.

A refused frame reaches onError alone as a LiveError and nothing fans out — show it, never retry it in a loop. A frame is capped at 32 KB and a presence update at 1 KB. Publishing runs at 100 frames per 10s and presence at 600 per 10s, so cursors and typing indicators belong in setPresence(), not in publish(); presence broadcasts are coalesced, so a room of cursors costs one roster frame per tick rather than one per keystroke. A space holds 1000 sockets and connecting is capped at 60/min per caller. A browser cannot read why a socket would not open, so both of those arrive as a connection that never comes up while the module keeps retrying — render room.connected honestly instead of blocking the screen on it. Keepalives are the module’s job too: it pings, and the server answers without waking anything.

watchCollection(name, handlers) subscribes to a Data collection. Every write you’d be able to read is pushed as it happens, so a view can re-render with no polling:

import { collection } from "data";
import { watchCollection, applyChange } from "live";
const todos = collection("todos");
const feed = watchCollection("todos", {
onOpen: async () => { items = (await todos.list()).items; },
onChange: (change, record) => { items = applyChange(items, change, record); },
});
feed.stop();

onOpen fires on connect and on every reconnect, which is why the initial read belongs there: changes pushed while you were away are never replayed, and reading first would miss a write that landed between the response and the socket opening.

onChange is handed "put" or "delete" and the record. A delete pushes { id } alone, not the record that is gone. applyChange(items, change, record) takes both and answers the list with that change applied — replacing a record in place, appending a new one, dropping a deleted one, and keeping the newer of two frames for the same record, which is what makes a racing pair safe to apply in the order they arrive.

This is emergent: you don’t publish these events, the Data endpoint does, on every write. The socket follows the collection’s declared scope: a user collection pushes the subscriber’s own changes, a shared collection pushes every member’s, and a public collection pushes everyone’s to anyone listening.

A live-data socket only ever receives. Publishing or setting presence on one is refused.

Method & path Body Returns
WS /api/_live?room=<name> presence + message frames (members only; public/… is open)
WS /api/_live?data=<collection> data frames (that collection, as it changes)

That is what the import speaks, and the only reason to know it: the app’s server.mjs is a lone module with no module graph, so it cannot import live, and an upgrade through env.PLATFORM.fetch() targets the path above. In the browser there is one way to be realtime, and it is the import.

Live data is scoped exactly like the underlying collection — you are pushed precisely what GET /api/_data/<collection> would show you. A room is scoped by its name: public/… is open to anyone, every other name is members-only. Scope the rest of the name for the audience too (per-document, per-team), so who is in the room is right by construction.

Realtime is backed by a hibernating Durable Object in your account: idle rooms cost nothing, and a member’s identity survives hibernation. Each project gets one realtime plane every deploy shares, and shipping a new version of your app leaves it untouched — a deploy doesn’t drop open connections. (When Typillar upgrades the realtime runtime itself, sockets reconnect; the live import already does that for you.) There’s no new account permission to grant — just ask for something realtime and the agent turns it on.