---
title: "Realtime WebSocket Tutorial"
---

# Realtime WebSocket Tutorial

**Build:** default — no feature flag required
**Endpoints:** `GET /realtime/v1/websocket` (HTTP upgrade)

---

## UVP

Polling a SQL table at 1 Hz to feel "live" is a tax every dashboard pays. HeliosDB Nano broadcasts INSERT / UPDATE / DELETE events to a Phoenix-protocol WebSocket on port 8080, in the same message shape as Supabase Realtime. **No CDC pipeline, no replication slot, no Kafka.** Subscribe to `realtime:public:<table>` and receive each change as a JSON event.

:::caution[Known issue]
Known issue: realtime events are not delivered in the current release; tracked by the Nano team. The examples below show the subscription protocol; the change events they
display are what the channel delivers once the issue is fixed. Filtering
subscriptions by row-level security is planned. See
[Known Limitations](https://www.heliosdb.com/docs/nano/reference/compatibility/known-limitations/#open-issues).
:::

---

## Prerequisites

- HeliosDB Nano, current release (`heliosdb-nano --version`)
- `wscat` for the examples (`npm i -g wscat`)
- A second terminal for SQL writes
- About 10 minutes

---

## 1. Start the Server

```bash
heliosdb-nano start --memory --auth scram-sha-256 --password s3cret
```

The realtime endpoint comes up automatically with the HTTP listener on port 8080.

---

## 2. Create a Table to Watch

```bash
psql "postgresql://postgres:s3cret@127.0.0.1:5432/postgres" <<'SQL'
CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  customer    TEXT,
  total       NUMERIC(10, 2),
  status      TEXT DEFAULT 'pending',
  updated_at  TIMESTAMP DEFAULT now()
);
SQL
```

The `ChangeNotifier` hooks into the DML execution path, so any INSERT / UPDATE / DELETE on this table — from psql, mysql, REST, or an embedded call — emits an event.

---

## 3. Connect with `wscat`

```bash
wscat -c ws://localhost:8080/realtime/v1/websocket
```

```
Connected (press CTRL+C to quit)
>
```

The connection is open; the server is now waiting for a `phx_join` message.

If `change_notifier` is not configured on the server, you get a one-shot reply:

```json
{"event":"system","payload":{"status":"error","message":"Realtime notifications are not enabled on this server"}}
```

…and the socket closes. The default `start` command always enables it.

---

## 4. Subscribe to a Table — `phx_join`

Paste this into the `wscat` prompt:

```json
{"event":"phx_join","topic":"realtime:public:orders","ref":"1","payload":{}}
```

Server replies:

```json
{"event":"phx_reply","topic":"realtime:public:orders","ref":"1","payload":{"status":"ok","response":{}}}
```

The topic format follows the Supabase / Phoenix convention `realtime:<schema>:<table>`. Nano parses the rightmost segment as the table name. You can also pass the table inside `payload.config.postgres_changes[].table` — see §7.

To subscribe to **every** table on the server use the wildcard `realtime:*`:

```json
{"event":"phx_join","topic":"realtime:*","ref":"2","payload":{}}
```

---

## 5. Trigger Changes from a Second Terminal

```bash
psql "postgresql://postgres:s3cret@127.0.0.1:5432/postgres" <<'SQL'
INSERT INTO orders (customer, total) VALUES ('Alice', 49.99);
UPDATE orders SET status = 'paid', updated_at = now() WHERE id = 1;
DELETE FROM orders WHERE id = 1;
SQL
```

In the `wscat` window, three events arrive in order:

```json
{"event":"postgres_changes","payload":{
  "type":"INSERT",
  "table":"orders",
  "record":{"id":1,"customer":"Alice","total":"49.99","status":"pending", ...},
  "old_record":null,
  "commit_timestamp":"2026-04-26T10:01:23Z"
}}
```

```json
{"event":"postgres_changes","payload":{
  "type":"UPDATE",
  "table":"orders",
  "record":{"id":1,"customer":"Alice","total":"49.99","status":"paid", ...},
  "old_record":{"id":1,"customer":"Alice","total":"49.99","status":"pending", ...},
  "commit_timestamp":"2026-04-26T10:01:24Z"
}}
```

```json
{"event":"postgres_changes","payload":{
  "type":"DELETE",
  "table":"orders",
  "record":null,
  "old_record":{"id":1,"customer":"Alice","total":"49.99","status":"paid", ...},
  "commit_timestamp":"2026-04-26T10:01:25Z"
}}
```

Payload shape:

| Field | INSERT | UPDATE | DELETE |
|-------|:-----:|:------:|:------:|
| `record` (after) | yes | yes | null |
| `old_record` (before) | null | yes | yes |
| `commit_timestamp` | RFC 3339, UTC | same | same |

---

## 6. Heartbeat and Unsubscribe

### Heartbeat — keep the connection alive

Phoenix clients send a heartbeat every ~30 s on the `phoenix` topic. The server replies `phx_reply` with `status: ok`.

```json
{"event":"heartbeat","topic":"phoenix","ref":"42","payload":{}}
```

```json
{"event":"phx_reply","topic":"phoenix","ref":"42","payload":{"status":"ok","response":{}}}
```

WebSocket-level pings (Ping / Pong frames) also work — the server echoes Pong.

### Unsubscribe — `phx_leave`

```json
{"event":"phx_leave","topic":"realtime:public:orders","ref":"99","payload":{}}
```

The server decrements the subscriber count; when it hits zero the table drops out of the broadcast filter and future DML on that table no longer serialises an event.

---

## 7. JavaScript Client

Use the browser's native `WebSocket` — no SDK required:

```javascript
const token = "..." // from /auth/v1/token, optional
const url = `ws://localhost:8080/realtime/v1/websocket?apikey=${encodeURIComponent(token)}`
const ws = new WebSocket(url)

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({
    event: "phx_join",
    topic: "realtime:public:orders",
    ref: "1",
    payload: {
      config: {
        postgres_changes: [
          { event: "*", schema: "public", table: "orders" }
        ]
      }
    }
  }))

  // keepalive
  setInterval(() => ws.send(JSON.stringify({
    event: "heartbeat",
    topic: "phoenix",
    ref: String(Date.now()),
    payload: {}
  })), 30_000)
})

ws.addEventListener("message", (msg) => {
  const m = JSON.parse(msg.data)
  if (m.event === "postgres_changes") {
    console.log(m.payload.type, m.payload.table, m.payload.record)
  }
})
```

Pass `?apikey=<jwt>` (or `?token=<jwt>`) on the upgrade URL if your front-end already holds a session from `/auth/v1/token`. The server logs the presence of the token; per-table authorization via JWT claims is planned, so keep the HTTP listener on loopback (the default).

### Supabase JS SDK — drop-in compatibility

```javascript
import { createClient } from "@supabase/supabase-js"
const supabase = createClient("http://localhost:8080", "any-key")
supabase.channel("orders")
  .on("postgres_changes",
      { event: "*", schema: "public", table: "orders" },
      (p) => console.log(p))
  .subscribe()
```

The topic, event names, and payload shape match the Supabase Realtime contract.

---

## 8. Architecture in One Diagram

```
   psql / mysql / REST / embedded API
           │
           ▼
     EmbeddedDatabase::execute()
           │   (after commit)
           ▼
       ChangeNotifier
       broadcast::Sender ──────► broadcast::Receiver per WS connection
                                 │
                                 ▼
                         JSON-encode + filter by subscribed tables
                                 │
                                 ▼
                          ws.send(Message::Text(...))
```

`ChangeNotifier` is a single `tokio::sync::broadcast` channel with a 1024-event buffer. Slow clients lag behind and emit a server-side `WS client lagged by N events` warning; they catch up at the next event but lose events older than the buffer window. For high-fanout deployments, give the server enough cores to drain the channel fast.

DML that happens on a table with **zero subscribers** never serialises a payload — the broadcast is short-circuited at `ChangeNotifier::notify` after a single map lookup.

---

## Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| Connection drops with `system` error message | `change_notifier` is `None` on `AppState` | Use the default `start` command — it wires the notifier automatically |
| Subscribed but no events on INSERT | Table mismatch — topic was `users` but you wrote to `orders` | Resend `phx_join` with the correct topic, or use `realtime:*` |
| `WS client lagged by N events` in server log | Client reads slower than 1024-event buffer fills | Reduce write rate, or process events on a non-blocking task |
| WebSocket closes after a minute of silence | Reverse proxy idle timeout (nginx default 60s) | Send `heartbeat` events or raise `proxy_read_timeout` |
| Events arrive but `record` field is missing keys | Tuple includes columns added after the connection started | Reconnect — the change notifier serialises the row at write time, not at subscription time |

---

## Where Next

- [BAAS_REST_API](https://www.heliosdb.com/docs/nano/guides/tutorials/baas_rest_api_tutorial/) — produce events by writing through the REST surface.
- [AUTH_AND_OAUTH](https://www.heliosdb.com/docs/nano/guides/tutorials/auth_and_oauth_tutorial/) — issue the JWT you pass on `?apikey=...`.
- [SWAGGER_UI_QUICKSTART](https://www.heliosdb.com/docs/nano/guides/quickstart/swagger_ui_quickstart/) — POST through Swagger and watch events fly in another tab.
- [RLS_POLICY_MANAGEMENT](https://www.heliosdb.com/docs/nano/guides/rls_policy_management_tutorial/) — policies through the embedded API (RLS filtering of realtime subscriptions is planned).
