Realtime (WebSocket)
Connect to:
ws://127.0.0.1:3000/realtime
Subscribe
{ "type": "subscribe", "table": "todos" }
Optional row filter and auth token:
{
"type": "subscribe",
"table": "todos",
"id": "123",
"token": "<access_token>"
}
Unsubscribe
{ "type": "unsubscribe", "table": "todos" }
Change events
{
"type": "change",
"channel": "todos",
"table": "todos",
"op": "INSERT",
"id": "1",
"data": { "id": 1, "title": "Ship", "completed": 0 },
"ts": 1720000000
}
op is one of INSERT, UPDATE, DELETE, or INVALIDATE. Payload shape uses a single data field (the new row for INSERT/UPDATE, the old row for DELETE). INVALIDATE has no data; it means a relationship used by this resource's read rule changed, so cached state must be pulled/refetched. Its id is "*" for a table subscription or the subscribed row id for an exact subscription. The dependency table and row are never exposed. BLOB columns are base64 strings (same as REST). Timestamps (ts) are Unix seconds.
Error frames
Failures use a generic error frame with a code (not ad-hoc auth_error / subscribe_error types):
{ "type": "error", "code": "AUTH_ERROR", "message": "invalid or expired token", "requestId": "..." }
{ "type": "error", "code": "SUBSCRIBE_ERROR", "table": "todos", "message": "...", "requestId": "..." }
When credentials that were valid at subscribe time expire or become invalid,
the server sends one AUTH_ERROR per affected subscription and mutes it. It
does not repeat the error for every CDC event. Send a fresh auth frame and a
new subscribe frame to clear the mute; the TypeScript SDK does this when its
token is refreshed.
How it works
- SQLite triggers write rows into
_cdc_log. - A background processor authorizes candidates and enqueues events for subscribers.
- After restart, unprocessed
_cdc_logrows are resumed.
Successful insertion into a client queue means enqueued, not received or processed by a browser. A full queue retires that client without pinning the global CDC cursor. Durable sync/pull remains authoritative and recovers gaps after reconnect.
Authorization
Subscribe and read rules from config filter which events a client receives. Unauthorized events are not delivered.
Connection heartbeat
The server sends a WebSocket protocol-level Ping 25 seconds after connection
and after each successful heartbeat. Standards-compliant WebSocket clients,
including browsers, answer with the matching Pong automatically; application
code does not need to send or handle JSON heartbeat messages.
The server waits 10 seconds for each matching pong. It retries once after the
first timeout, then closes the connection with code 1001 and reason
heartbeat timeout after the second consecutive miss. Heartbeat control frames
are not counted as realtime application messages or bytes.
This protocol heartbeat remains enabled and is independent from the application heartbeat below; neither replaces nor weakens the other.
Application heartbeat
While at least one subscription is active, supported SDKs also probe the end-to-end text-frame path used by change events:
{ "type": "ping", "requestId": "hb_unique", "sentAt": 1787814926000 }
The server places the response in the normal per-client text send queue:
{ "type": "pong", "requestId": "hb_unique", "serverTs": 1787814926 }
requestId is the correlation nonce. Only its matching pong acknowledges the
current probe; stale or unsolicited pongs do not. The server reflects at most
128 bytes and safely omits a larger nonce. Legacy {"type":"ping"} remains
valid and receives a pong without requestId.
The TypeScript SDK probes every 25 seconds by default and waits 12 seconds for
the matching pong. A timeout marks realtime stale, retires the socket even when
its browser readyState is still OPEN, reconnects with the existing jittered
path, reauthenticates, resubscribes, and runs REST catch-up. Page show,
visibility restoration, and online transitions trigger an immediate probe or
stale-socket replacement.
Reconnect
Clients should reconnect and re-send subscribe messages after network drops.
The TypeScript, Swift, and Flutter/Dart SDKs:
- Detect unexpected WebSocket close. The TypeScript SDK also detects a missing application pong while the socket is still open.
- Reconnect with exponential backoff + full jitter (base ~1s, cap 30s).
- Re-subscribe every active table/row subscription on open, including the current access token.
- After reconnect, REST catch-up emits local
op: "RESYNC"change events (current authorized state; not a full CDC replay).
After a committed blue/green deployment cutover, the retiring server closes
application and Studio sockets with code 1012 and reason service restart.
The replacement is already serving traffic, so clients reconnect with their
normal jitter rather than waiting for a separate readiness signal. Heartbeat
timeouts continue to use 1001 and reason heartbeat timeout.
const unsub = client.from("todos").subscribe((ev) => console.log(ev));
// ... network blip: SDK reconnects and re-sends subscribe automatically
unsub(); // remove this subscription
client.closeRealtime(); // stop reconnect loops entirely
let unsub = client.from("todos").subscribe { ev in print(ev) }
unsub()
client.closeRealtime()
final unsub = client.from('todos').subscribe((ev) => print(ev));
unsub();
client.closeRealtime();
Change payload shape
Realtime change frames use a single data field (PRD §9 / shipped protocol):
{
"type": "change",
"channel": "todos",
"table": "todos",
"op": "INSERT",
"id": "1",
"sequence": 42,
"data": { "id": 1, "title": "hi" },
"ts": 1710000000
}
datais the new row for INSERT/UPDATE and the old row for DELETE.INVALIDATEomitsdataand requires a pull/refetch; do not merge it as a row mutation.sequenceis the durable_cdc_log.idfor client deduplication.- There is no separate
old/newpair in the wire protocol (older PRD §10 drafts mentioned that shape; the implementation and this guide resolve the contradiction in favor of singledata).