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.
Rooms — presence and broadcast
Section titled “Rooms — presence and broadcast”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 roomroom.sendTo("usr_7", { text: "just you" }); // one member aloneroom.setPresence({ cursor: [12, 40], status: "typing" }); // your own state, re-broadcast
room.leave(); // the socket closes when the last handle leavesEvery 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.
Who is in a room
Section titled “Who is in a room”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.
Who a member is
Section titled “Who a member is”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.
Limits
Section titled “Limits”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.
Live data
Section titled “Live data”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.
The wire
Section titled “The wire”| 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.
Isolation
Section titled “Isolation”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.
How it’s built
Section titled “How it’s built”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.