---
title: "Realtime · Apphaven"
description: "Changes as they happen: channels that carry record changes, broadcasts and presence to every open page."
url: "https://apphaven.com/docs/agent/realtime/"
---

# 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.

```js
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:

```js
{ 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

```yaml
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 }
```

```js
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:

```js
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.
