Realtime Overview
AnvilBase Realtime is an Elixir/Phoenix service that delivers live updates over WebSockets. It runs on the BEAM VM, which is built for huge numbers of concurrent connections. There are three capabilities:
- Postgres Changes — stream
INSERT/UPDATE/DELETEevents from your tables (Postgres Changes). - Broadcast — low-latency pub/sub messaging between clients (Broadcast & Presence).
- Presence — track who’s online in a channel (Broadcast & Presence).
The wire protocol matches @supabase/realtime-js, so the Supabase clients’
realtime API works against AnvilBase.
Connecting
The WebSocket endpoint, proxied through the control plane, is:
ws(s)://<host>/v1/realtime/<project_id>/socket/websocket?token=<jwt>&vsn=2.0.0- Authentication is via the
tokenquery parameter (a project JWT or the service_role key) — not anAuthorizationheader (you can’t set headers on a browser WebSocket). The token’s claims (project_id,sub/user id,role) are validated on connect. vsn=2.0.0selects the Phoenix v2 message format.
With the SDK you don’t build this by hand:
import { createClient } from '@supabase/supabase-js'
const db = createClient(`http://localhost:39001/v1/<project_id>`, ANON_KEY, { realtime: { params: { /* token is taken from the session */ } },})Topics and channels
Realtime is organized into channels, identified by a topic string of the form:
realtime:<custom_topic>For database streaming, the convention mirrors Supabase:
realtime:public:<table> (or a more specific realtime:public:<table>:<filter>).
You join a channel, then declare what you want to receive on it.
Quick example (SDK)
const channel = db .channel('room-1') // topic: realtime:room-1 .on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, (payload) => console.log('db change', payload)) .on('broadcast', { event: 'cursor' }, (msg) => console.log('cursor', msg.payload)) .subscribe((status) => console.log('channel status', status))
// laterchannel.unsubscribe()Quick example (CLI)
The CLI can subscribe to a channel for debugging:
export ANVILBASE_TOKEN="<jwt-or-service-role-key>"anvilbase realtime subscribe --project <id> --channel "realtime:public:messages"It prints each incoming event as [<topic>] <event> <payload-json> until Ctrl-C.
The raw Phoenix protocol (when you’re not using the SDK)
Phoenix v2 frames are JSON tuples:
[join_ref, ref, topic, event, payload]To join a channel and start receiving Postgres changes, send a phx_join:
["1","1","realtime:public:messages","phx_join", {"config":{"postgres_changes":[{"event":"*","schema":"public","table":"messages"}]}}]The server replies on phx_reply with the granted subscriptions (each gets a
stable integer id), then pushes postgres_changes events as rows change. You must
send a periodic heartbeat on the phoenix topic to keep the socket alive.
Most users should let the SDK handle all of this; the raw protocol is documented for non-JS clients and debugging.
Authorization
Realtime respects your project’s auth: the connection token determines the role.
Postgres-change payloads are produced by the database change feed — pair Realtime
with RLS on the underlying tables so clients
only subscribe to data they could otherwise read. Use the service_role token only
in trusted server-side subscribers.
Token lifecycle (refresh & expiry)
A realtime socket’s JWT is not frozen for the life of the connection.
Mid-session token refresh (access_token)
When the user’s session token rotates (e.g. a refresh-token rotation), the client
pushes the new token on the channel as an access_token message — the
@supabase/realtime-js clients do this automatically:
["1","9","realtime:public:messages","access_token",{"access_token":"<new-jwt>"}]AnvilBase re-verifies the new token against the project’s JWT secret, confirms it
belongs to this socket’s project, and rejects it if it is already expired.
On success the socket adopts the new token’s claims, so every subsequent
postgres_changes event is RLS-checked against the new identity (and the new
expiry). A bad refresh (wrong signature, wrong project, expired, malformed) is
rejected: the server pushes a system error and the previous token keeps
governing the session until it expires.
Mid-session expiry enforcement
A token that expires mid-session does not keep receiving change events until
the socket happens to drop. AnvilBase periodically re-checks the live token’s
exp; once it passes — and no valid access_token refresh has moved it
forward — the server pushes a system error ("access token expired") and
closes the channel. Delivery is also guarded per event: once the token is
expired, every postgres_changes event is dropped immediately, so nothing leaks
in the gap between expiry and the channel teardown. Tokens minted without an
exp claim never expire this way. The practical upshot: keep the SDK’s session
fresh (it refreshes for you), or expect the socket to be torn down when the token
lapses.
Per-project isolation
Channels are namespaced per project — a token for project A can’t join project B’s topics. See Multi-Tenancy.
Next: Postgres Changes.