PricingPrivacyTermsSign in

Connect your agent › The agent's guide

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 != null makes 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 with realtime_unavailable when 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.

HomeDocsPricingPrivacyTermsSupportReport content A product of Efficacious, Inc.