Realtime
A live query is a subscription. There is no publish verb, no channel object
and no socket API: a write is the event, and a read with .live() is the
subscription. For why it is built this way, see
docs/realtime.md. This page is the callable contract.
Status. Implemented end to end: transport, ticket handshake, resume,
post-commit fan-out, and the SDK's .live(). Only metering is still
proposed, and it is marked as such below.
The shape in one screen#
// Subscribe
const sub = tallpond
.resource(roomId)
.table("messages")
.select("id, body, author")
.orderBy("createdAt", "asc")
.live();
sub.on("insert", appendMessage);
sub.on("update", patchRow);
sub.on("delete", dropRow);
sub.on("status", (s) => showBanner(s)); // 'connecting' | 'live' | 'resyncing' | 'offline'
// This is the publish. There is no emit, no broadcast, no socket call.
await tallpond.resource(roomId).table("messages").insert({ body });
sub.close();
Scopes, and how many sockets you get#
A scope is owner_principal — the value every read and write already
filters on. It takes exactly two forms:
| Scope | Value | Contents |
|---|---|---|
| Private | your own principal id | your rows, across all tables |
| Resource | a resource id | that resource's rows |
One socket per scope, not per query and not per client. Two live queries on the same scope share one socket; two on different scopes use two. A client watching twelve resources holds twelve sockets, which browsers tolerate comfortably.
The SDK hides this — you never count sockets — but the number is predictable, which matters on metered mobile connections and is why it is documented rather than treated as an implementation detail.
Who may subscribe#
The table's existing read rule. Who may publish is its existing create
rule. There is no subscribe or publish rule to write or keep in agreement
with anything.
Two consequences worth knowing before you design around them:
- A
via-gated table is invisible to a live query.viarules mean "only from inside these functions", and a socket is never inside a function invocation. A subscription that bypassed aviagate would read through a door the direct path keeps shut. - Creator-scoped access (
'creator') does not subscribe. On a shared (creator-owned) table,'creator'means "your own rows only" — a per-row predicate. Fan-out cannot evaluate per-row ownership today, so such tables are excluded from live delivery entirely rather than partially. A table whosereadis['creator', 'member']is live for members.
What publishes#
A write is the event, so there is nothing to call. What reaches subscribers:
| You did | Subscribers get |
|---|---|
insert (n rows) | n change events, op: "insert" |
update (n rows hit) | n change events, op: "update" |
delete (n rows hit) | n change events, op: "delete", row is { id } only |
upsert | op: "insert" — see below |
select / count | nothing |
Four rules behind that table:
- Events come from
returning, not from what you sent. An update whose filter matched nothing publishes nothing, and therowyou receive is the row as the database now holds it — server defaults,updatedAtand all. upsertpublishes asinsertbecause Postgres does not report which branch it took. This is safe only under the rule resume already forces on you: dedupe by rowid, which makesinsertmean upsert-by-id on your side too.- A batch publishes on commit or not at all.
POST /v1/db/batchcollects every operation's events and publishes them after the transaction commits, so a rollback never announces a row that no longer exists. Ordering within a scope follows operation order. - Mounted (creator-owned) tables publish nothing at all — not to the
resource scope, and not to the creator's private scope either. Same reason
they are unsubscribable:
'creator'is a per-row predicate. If you need a shared table to be live, declare it withowns, notshares.
Delivery is at-most-once: a failed notification is not retried, and never
fails your write. resume is what makes the end-to-end guarantee at-least-once.
Access is evaluated once, when the socket opens. A role revoked mid-session is not observed until the client reconnects. Treat live delivery as authoritative for what changed, never as a permission check with fresh information.
The one exception: membership#
Resource membership isn't a declared app table, so the read/create rule
above doesn't apply to it — it gets a fixed rule instead. Invites, accepts,
role changes, leaves and removals publish under a reserved table name, always
visible on your own private scope, and visible on a resource's scope to that
resource's active members (any role). See
resources.md for the two SDK entry points
(resource(id).members.live() and tallpond.resources.live()) and the row
shape — it's thinner than an ordinary table row, { id, resourceId, userId, role, state }, since nothing here goes through a declared schema.
POST /v1/realtime/ticket#
Mints the short-lived credential that opens sockets. Ordinary authenticated
request: session cookie plus x-tallpond-csrf, or a tpa_/tpt_ bearer.
Why this exists. A browser cannot set custom headers on a WebSocket handshake, so a socket cannot carry the CSRF header that protects every other cookie-authed request. Rather than exempt the handshake, authentication moves off it. A cross-site page can open a socket to us; it cannot obtain a ticket. The ticket is the CSRF defense.
POST /v1/realtime/ticket
x-tallpond-csrf: 1
{
"ticket": "tpr_eyJraW5kIjoicmVhbHRpbWUi….Ab3f…",
"principal": "usr_abc",
"expiresInMs": 30000,
"subprotocol": "tallpond.v1"
}
principal is your own scope value — what ?scope= must be to subscribe to
your private data. You cannot derive it any other way, and a mint already
precedes every connect, so it is returned here rather than behind a second
call.
The ticket is principal-scoped, not scope-scoped: one mint opens as many sockets as you like inside its 30-second window. It grants no authority of its own — every socket is authorized independently — so widening it costs nothing and saves a round trip per scope.
| Status | Error | Meaning |
|---|---|---|
| 400 | app_context_required | the session is not bound to an app |
| 404 | app_not_deployed | the app has no active deployment |
| 503 | realtime_not_configured | this gateway has no signing secret |
GET /realtime/connect?scope=<scope>#
The upgrade. Note the path is not under /v1 — see above.
new WebSocket(`${gateway}/realtime/connect?scope=${scope}`, [
"tallpond.v1",
`ticket.${ticket}`,
]);
The ticket travels in the subprotocol, never the query string: query
strings land in proxy logs, access logs and Referer headers.
| Status | Error | Meaning |
|---|---|---|
| 400 | scope_required | no scope parameter |
| 401 | missing_ticket | no ticket. entry in the subprotocol |
| 401 | invalid_or_expired_ticket | forged, tampered, or past its 30s |
| 403 | scope_forbidden | the principal may read nothing in this scope |
| 426 | expected_websocket_upgrade | not an upgrade request |
scope_forbidden is returned rather than an empty subscription. A socket that
can never deliver anything is indistinguishable from a working one and fails
silently, which is the worst shape an authorization bug can take.
Wire protocol (tallpond.v1)#
JSON text frames. t is the tag.
Server → client#
hello — once, on connect.
{ "t": "hello", "v": 1, "seq": 412, "scope": "res_abc" }
seq is the scope's current watermark, so a client with no prior state knows
where "now" is without guessing.
change — a committed write.
{
"t": "change",
"seq": 413,
"table": "messages",
"op": "insert",
"row": { "id": "msg_1", "body": "hi", "author": "usr_2" }
}
op is insert, update or delete uniformly. An append-only log happens to
use one of the three; nothing in the transport privileges that case.
resync — the gap cannot be served from the buffer.
{ "t": "resync", "seq": 900, "reason": "buffer_evicted" }
reason | Meaning |
|---|---|
buffer_evicted | your gap is older than the ring buffer |
buffer_cold | the buffer is gone (storage loss) |
watermark_ahead | you cite events this scope never issued — its state was lost |
Not an error. Refetch with the ordinary paginated read you already make on cold start; it is metered as the read it is.
error
{
"t": "error",
"code": "bad_message",
"message": "unparseable or unknown message"
}
Client → server#
Deliberately tiny. Every authorization decision was made before the socket existed, so there is almost nothing here to attack.
resume — replay from a watermark.
{ "t": "resume", "after": 412 }
watch — narrow to a subset of tables.
{ "t": "watch", "tables": ["messages"] }
Purely a bandwidth optimization on your behalf. It can never widen access: the allowed set was fixed when the socket opened, and naming a table outside it delivers nothing.
Delivery guarantees#
| Property | Guarantee |
|---|---|
| Ordering | total order per scope, by seq, assigned by the coordinator |
| Live delivery | at-most-once — a dropped notification is not retried |
| With resume | at-least-once — dedupe by row id, which you already have |
| Across scopes | no ordering — two scopes are independent |
seq comes from the scope's coordinator, never from Postgres. A database
sequence cannot do this job: nextval is assigned at insert time but rows
become visible at commit time, and those orders differ, so a client resuming
from seq > 100 would silently miss a row that took 98 and committed later.
Sockets drop, by design. Idle connections are hung up on by the network
after roughly 20–75 minutes (measured — see docs/realtime.md).
There is no keepalive. Reconnect, send resume with your last seq, and
handle resync. The SDK does this for you; reconnect with jittered backoff
if you implement a client yourself, because drops arrive correlated and a
coordinator is single-threaded.
What the coordinator does not do — read this before designing a UI#
It does not evaluate your query. .live() subscribes to a table in a
scope. Neither the where filter nor the select projection is applied on the
server:
- You receive every change to that table in that scope, including rows your filter excludes. The SDK filters client-side.
rowcarries the full row, not your projection.
Two consequences that shape application code:
- Bandwidth is per-table, not per-query. A narrow filter over a busy table
is not a cheap subscription. If a scope has a hot table you do not care
about, use
watch. - A row can leave your filter. If
done: falseis your filter and a row updates todone: true, the wire carries anupdatefor a row that no longer matches. It is not a wire-leveldelete. The SDK handles this (see below); a hand-written client that treatsupdateas "patch if present" will leave stale rows on screen.
This is a deliberate v1 boundary: server-side predicate evaluation means compiling and running user filters inside the coordinator, which is executable logic in a component that today holds none.
What the SDK's .live() adds on top#
The wire protocol reports what happened to the table. .live() reports what
happened to your list, which is not the same thing. Everything here is
client-side, and a hand-written client gets none of it for free.
- Your
whereis applied to every event. A change to a row your filter excludes is dropped before your handler sees it. - Your
selectis applied to every event. You receive your projection, not the full row. (The snapshot read silently asks foridas well, since reconciliation is by id — you are not billed differently and you do not see it unless you selected it.) - Filter crossings become the event your view needs. A row that stops
matching is delivered to you as
delete; a row that starts matching is delivered asinsert, even though the wire saidupdatein both cases. resyncis handled by refetching and reconciling. Rows that disappeared while you were away are emitted asdelete— a plain refetch-and-insert would leave them on screen forever, since no future event mentions them.- The initial read happens after the socket is live, and events arriving during it are buffered, so no write can fall between the snapshot and the stream.
- Reconnect is automatic, with full jitter, and
resumecarries the lastseqyou saw rather than the one the new socket announced.
sub.on("status", (s) => ...); // 'connecting' | 'live' | 'resyncing' | 'offline'
sub.on("error", (e) => ...); // ticket failures; a connection that keeps failing
sub.close();
delete handlers always receive { id } and nothing else — for a real delete
there is nothing else left, and a filter departure is deliberately reported the
same way.
Not applied client-side: orderBy and limit. Events are individual
changes, not a list, so ordering is yours to maintain. A federated read
(tallpond.resources({type})) cannot .live() at all — it spans many scopes,
so there is no single object to subscribe to; it throws rather than silently
picking one.
In runtimes with no global WebSocket (Node before 22, some React Native
setups) pass one: createClient({ …, webSocket }).
Metering#
Publishing is metered; subscribing is not. Connection-time is never billed.
The initial query behind a .live() is metered as the ordinary read it is.
A publish is billed to whoever the write was billed to, as its own
realtime_publish line, and it is priced on the thing that actually costs
money — waking a coordinator:
| Situation | Charge |
|---|---|
| Nobody is subscribed to that scope | free |
| The scope was idle (coordinator asleep) | ~21 µUSD |
| The scope is active (already resident) | ~3 µUSD |
Fan-out size is not a billing dimension. Delivering to 2 subscribers and to 200 costs the same, because on Cloudflare it does: outbound WebSocket messages are not billed, and the CPU to send them is inside a wake already paid for.
The practical shape: a chatty room pays the wake charge once and then pennies, because it stays resident. A quiet one pays per message, which is what it genuinely costs. And an app with no live subscribers pays nothing for realtime at all — you are not charged for a feature nobody is using, even on a table somebody could subscribe to.
Full reasoning, including why this is the one charge settled after the fact
rather than escrowed, is in docs/metering.md.
Deferred#
Named so you do not design around their absence and find them arriving later in a shape you did not expect.
| Deferred | Note |
|---|---|
| Presence / typing | the one genuinely new API surface here; needs ephemeral state and a TTL |
Server-side where | see above |
| Cross-scope multiplexing | one socket per scope stands; a session tier is a mobile-radio optimization |
| Live files and buckets | .live() is table-only |
| Live mounted tables | needs per-row owner_principal filtering in the coordinator |
channel() / presence() DSL | parsed today, never enforced; deprecated rather than implemented |