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#

ts
// 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:

ScopeValueContents
Privateyour own principal idyour rows, across all tables
Resourcea resource idthat 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:

What publishes#

A write is the event, so there is nothing to call. What reaches subscribers:

You didSubscribers 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
upsertop: "insert" — see below
select / countnothing

Four rules behind that table:

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.

http
POST /v1/realtime/ticket
x-tallpond-csrf: 1
json
{
  "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.

StatusErrorMeaning
400app_context_requiredthe session is not bound to an app
404app_not_deployedthe app has no active deployment
503realtime_not_configuredthis gateway has no signing secret

GET /realtime/connect?scope=<scope>#

The upgrade. Note the path is not under /v1 — see above.

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

StatusErrorMeaning
400scope_requiredno scope parameter
401missing_ticketno ticket. entry in the subprotocol
401invalid_or_expired_ticketforged, tampered, or past its 30s
403scope_forbiddenthe principal may read nothing in this scope
426expected_websocket_upgradenot 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.

json
{ "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.

json
{
  "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.

json
{ "t": "resync", "seq": 900, "reason": "buffer_evicted" }
reasonMeaning
buffer_evictedyour gap is older than the ring buffer
buffer_coldthe buffer is gone (storage loss)
watermark_aheadyou 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

json
{
  "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.

json
{ "t": "resume", "after": 412 }

watch — narrow to a subset of tables.

json
{ "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#

PropertyGuarantee
Orderingtotal order per scope, by seq, assigned by the coordinator
Live deliveryat-most-once — a dropped notification is not retried
With resumeat-least-once — dedupe by row id, which you already have
Across scopesno 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:

Two consequences that shape application code:

  1. 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.
  2. A row can leave your filter. If done: false is your filter and a row updates to done: true, the wire carries an update for a row that no longer matches. It is not a wire-level delete. The SDK handles this (see below); a hand-written client that treats update as "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.

ts
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:

SituationCharge
Nobody is subscribed to that scopefree
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.

DeferredNote
Presence / typingthe one genuinely new API surface here; needs ephemeral state and a TTL
Server-side wheresee above
Cross-scope multiplexingone socket per scope stands; a session tier is a mobile-radio optimization
Live files and buckets.live() is table-only
Live mounted tablesneeds per-row owner_principal filtering in the coordinator
channel() / presence() DSLparsed today, never enforced; deprecated rather than implemented