Loomup Docs
Guide /docs/realtime

Realtime (WebSocket)

Connect to:

code
ws://127.0.0.1:3000/realtime

Subscribe

json
{ "type": "subscribe", "table": "todos" }

Optional row filter and auth token:

json
{
  "type": "subscribe",
  "table": "todos",
  "id": "123",
  "token": "<access_token>"
}

Unsubscribe

json
{ "type": "unsubscribe", "table": "todos" }

Change events

json
{
  "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):

json
{ "type": "error", "code": "AUTH_ERROR", "message": "invalid or expired token", "requestId": "..." }
json
{ "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

  1. SQLite triggers write rows into _cdc_log.
  2. A background processor authorizes candidates and enqueues events for subscribers.
  3. After restart, unprocessed _cdc_log rows 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:

json
{ "type": "ping", "requestId": "hb_unique", "sentAt": 1787814926000 }

The server places the response in the normal per-client text send queue:

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

  1. Detect unexpected WebSocket close. The TypeScript SDK also detects a missing application pong while the socket is still open.
  2. Reconnect with exponential backoff + full jitter (base ~1s, cap 30s).
  3. Re-subscribe every active table/row subscription on open, including the current access token.
  4. 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.

ts
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
swift
let unsub = client.from("todos").subscribe { ev in print(ev) }
unsub()
client.closeRealtime()
dart
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):

json
{
  "type": "change",
  "channel": "todos",
  "table": "todos",
  "op": "INSERT",
  "id": "1",
  "sequence": 42,
  "data": { "id": 1, "title": "hi" },
  "ts": 1710000000
}
  • data is the new row for INSERT/UPDATE and the old row for DELETE.
  • INVALIDATE omits data and requires a pull/refetch; do not merge it as a row mutation.
  • sequence is the durable _cdc_log.id for client deduplication.
  • There is no separate old/new pair in the wire protocol (older PRD §10 drafts mentioned that shape; the implementation and this guide resolve the contradiction in favor of single data).