Skip to content

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)
  • wscat for the examples (npm i -g wscat)
  • A second terminal for SQL writes
  • About 10 minutes

1. Start the Server

Terminal window
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

Terminal window
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

Terminal window
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:

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

Terminal window
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:

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

FieldINSERTUPDATEDELETE
record (after)yesyesnull
old_record (before)nullyesyes
commit_timestampRFC 3339, UTCsamesame

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, 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 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

SymptomCauseFix
Connection drops with system error messagechange_notifier is None on AppStateUse the default start command — it wires the notifier automatically
Subscribed but no events on INSERTTable mismatch — topic was users but you wrote to ordersResend phx_join with the correct topic, or use realtime:*
WS client lagged by N events in server logClient reads slower than 1024-event buffer fillsReduce write rate, or process events on a non-blocking task
WebSocket closes after a minute of silenceReverse proxy idle timeout (nginx default 60s)Send heartbeat events or raise proxy_read_timeout
Events arrive but record field is missing keysTuple includes columns added after the connection startedReconnect — the change notifier serialises the row at write time, not at subscription time

Where Next