Realtime: changes as they happen
When several people look at the same thing at once (a game table, a chat room, a shared board,
orders arriving in a kitchen, a dashboard), don't poll. app.realtime keeps one WebSocket open
per page; the page subscribes to channels, and on a channel come three kinds of traffic:
- changes: a record was created, updated or deleted;
- broadcast: messages between the people on the channel, never stored (cursors, typing, "your turn");
- presence: who is on the channel right now, with a little state each.
Everything a page shows still comes from app.db and app.ops; realtime tells it when to
look again, and often brings the record along. An app whose socket can't open keeps working
from its fetches.
const table = app.realtime.channel(`Game:${gameId}`);
table.on("change", { model: "Game" }, (e) => { game = e.record ?? game; render(); });
table.on("change", { model: "Seat" }, () => loadSeats()); // no record: fetch
table.on("broadcast", { event: "cursor" }, ({ from, payload }) => drawCursor(from, payload));
table.on("presence", ({ members }) => showWhoIsHere(members));
table.on("status", (s) => { if (s === "resync") loadEverything(); });
await table.subscribe({ presence: { name: me.name } }).catch(() => {}); // no realtime here? the page still loads
await loadEverything();
table.send("cursor", { x, y }); // to the others
table.unsubscribe();
Channels
A channel is a record: Game:abc is the Game record abc. You may join it when the model's
get rule lets you read that record, checked once when you subscribe (as the signed-in person,
or signed out). user:<id> is each person's own channel, for things meant for one person (a
notification, "your order is ready"); only that person may join it. Nothing else exists: there
is no free-form channel to name.
- A public model (
read: true) has public channels.$user != nullmakes them for signed-in people. A row-level rule (host == $user.id || exists(Seat, …)) makes each game's channel its players'. subscribe()rejects with the same codes the API uses:unauthorized(sign in),not_found(no such record, or you can't see it),unknown_model,forbidden(another person's own channel); and withrealtime_unavailablewhen the app has no realtime right now (the SDK keeps trying and joins when it can). Catch it and load anyway: the page works from its fetches.- A page may be on at most 20 channels. The same
channel(name)call returns the same object.
Changes
A write to record R of model M is published on M:R and on the channel of every record R
points at through a ref or user field. So a Seat saved with game: abc reaches Game:abc,
a Message with room: xyz reaches Room:xyz, and a Notification with to: <user id> reaches
that person's user:<id>. Each event is:
{ channel, model, action: "create" | "update" | "delete", id, at, seq, record? }
record is there when the server could send it: when the model's read rule can be decided
without looking at the row (true, $user != null, $user.role == 'admin') and holds for you.
When the rule is row-level (owner == $user.id, exists(Membership, …)), or the model has
image or file fields, the event has no record: fetch it with app.db.M.get(id) or reload the
list. Either way the API applies the rules, so a private record never travels to someone who
can't read it. People on Game:abc do learn that a Hand with game: abc changed, and when,
not what it holds; if even that must stay private, don't point the private model at the shared
one.
What publishes: creates, updates and deletes made through the app (app.db, app.ops, the REST
API), by anyone. Records Postgres removes by cascade get no event of their own: the parent's
delete is the signal. Edits made from outside the app (your insert_records / update_record / delete_records, the owner's Data page,
a restore) don't publish; the resync status below covers them.
on("change", { model, action }, fn) filters; either key is optional.
Broadcast and presence
channel.send(event, payload) goes to the other people on the channel, never to you and never
stored; the payload is at most 8 KiB as JSON. Receivers get { channel, event, from, payload },
where from is the sender's user id (null if signed out). on("broadcast", { event }, fn) filters.
subscribe({ presence: state }) and channel.track(state) set what others see of you (at most
1 KiB). on("presence", ({ members }) => …) and channel.members give everyone on the channel:
{ key, user, state }, one per open page, user null when signed out.
Status, loading and reconnecting
Load the data after await channel.subscribe(): from then on every change reaches the page,
so nothing is missed between loading and listening. The status handler gets:
"joined"after each join (the first, and every reconnect);"dropped"when the socket goes; the SDK reconnects by itself with backoff, re-joins every channel, and replays the changes it missed when the gap was short (about ten minutes);"resync"when the gap was longer, or the tab was hidden for more than a minute: load again;"closed"when the channel's record was deleted; the channel can't be joined again.
app.realtime.status is "closed", "connecting", "open" or "unavailable" (three quick
failures: realtime isn't on here, or something in the way strips upgrades; the SDK keeps trying
every minute). app.realtime.onStatus(fn) reports changes. Signing out closes the socket;
signing in as someone else re-joins every channel as them.
A chat room
models:
Room:
fields: { name: { type: string, required: true } }
access: { read: $user != null, create: $user != null }
Message:
fields:
room: { type: Room, required: true }
author: { type: user, default: $user.id, readonly: true, required: true }
text: { type: text, required: true, max_length: 2000 }
access: { read: $user != null, create: $user != null, delete: author == $user.id }
const room = app.realtime.channel(`Room:${roomId}`);
room.on("change", { model: "Message", action: "create" }, (e) => append(e.record)); // read: $user != null → the record comes along
room.on("change", { model: "Message", action: "delete" }, (e) => remove(e.id));
room.on("broadcast", { event: "typing" }, ({ from }) => showTyping(from));
room.on("presence", ({ members }) => showOnline(members.map((m) => m.state?.name).filter(Boolean)));
room.on("status", (s) => { if (s === "resync") loadRecent(); });
await room.subscribe({ presence: { name: me.name } }).catch(() => {});
await loadRecent(); // app.db.Message.list({ filter: filter`room == ${roomId}`, sort: "-created_at", limit: 50, expand: ["author"] })
input.oninput = () => room.send("typing", {});
form.onsubmit = async (e) => { e.preventDefault(); await app.db.Message.create({ room: roomId, text: input.value }); input.value = ""; };
A chat message is just a record: it gets history, rules, moderation through the Data page, and
the author's name through expand. The broadcast is only for what nobody needs to keep.
A game table
Keep the version-checked write (an update operation with where: id == $params.id && version == $params.version, retried after a 409 or a count: 0): that is what makes concurrent moves
safe. Realtime replaces the polling around it:
const table = app.realtime.channel(`Game:${game.id}`);
table.on("change", { model: "Game" }, async (e) => { game = e.record ?? (await app.db.Game.get(game.id)); render(); });
table.on("change", { model: "Seat" }, loadSeats);
table.on("status", (s) => { if (s === "resync") reload(); });
await table.subscribe({ presence: { name: me.name } }).catch(() => {});
await reload();
A private model (each player's hand, read: owner == $user.id) arrives without a record: on its
event, app.db.Hand.list({ filter: filter\game == ${game.id}` })` as before. Anything the game
must do while nobody has it open (a timer, a bot's turn) isn't realtime's job: realtime makes
connected people see each other's moves at once.
Testing it
view_page runs a real browser. Exercise the page's subscription and a UI change in the same
call, waiting for the resulting screen. Each call closes its browser context: two sequential
calls do not represent two connected people and do not prove that a push reached another client.
For a multi-person check, use two simultaneously open browser sessions with separate app users,
make a change in one, and observe the other without reloading. Report this as unverified if you
cannot do it. run_tests does not exercise sockets or emit events. In preview, each real browser
also needs the owner's Apphaven preview access, separately from app sign-in.
Limits: 20 channels per page, 8 KiB per broadcast, 1 KiB of presence state, 20 frames a second
per page, and a few thousand open pages per app. Past them the server answers an error frame
with a code (channels_limit, too_large, rate_limited), or refuses the connection when the
app is full, and the page keeps working from its fetches.