Realtime WebSocket Tutorial
Realtime WebSocket Tutorial
Available since: v3.8.0 (2026-04-02)
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 from the storage engine to a Phoenix-protocol WebSocket on port 8080 — same speech as Supabase Realtime, so existing SDKs swap the URL and work unchanged. No CDC pipeline, no replication slot, no Kafka. Subscribe to realtime:public:<table> and receive each change as a JSON event the moment the DML commits.
Prerequisites
- HeliosDB Nano v3.8+ (
heliosdb-nano --version) wscatfor the examples (npm i -g wscat)- A second terminal for SQL writes
- About 10 minutes
1. Start the Server
heliosdb-nano start --memory --auth scram-sha-256 --password s3cretThe realtime endpoint comes up automatically with the HTTP listener on port 8080.
2. Create a Table to Watch
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());SQLThe 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
wscat -c ws://localhost:8080/realtime/v1/websocketConnected (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:
{"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:
{"event":"phx_join","topic":"realtime:public:orders","ref":"1","payload":{}}Server replies:
{"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:*:
{"event":"phx_join","topic":"realtime:*","ref":"2","payload":{}}5. Trigger Changes from a Second Terminal
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;SQLIn the wscat window, three events arrive in order:
{"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"}}{"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"}}{"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.
{"event":"heartbeat","topic":"phoenix","ref":"42","payload":{}}{"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
{"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:
const token = "..." // from /auth/v1/token, optionalconst 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 on the roadmap — for v3.8 access is open to any client that can reach the port.
Supabase JS SDK — drop-in compatibility
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 — produce events by writing through the REST surface.
- AUTH_AND_OAUTH — issue the JWT you pass on
?apikey=.... - SWAGGER_UI_QUICKSTART — POST through Swagger and watch events fly in another tab.
- RLS_POLICY_MANAGEMENT — production policies for multi-tenant realtime.