Skip to content

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:

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 token query parameter (a project JWT or the service_role key) — not an Authorization header (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.0 selects 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))
// later
channel.unsubscribe()

Quick example (CLI)

The CLI can subscribe to a channel for debugging:

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