Postgres Changes
Subscribe to a table and receive a live event every time a row is inserted,
updated, or deleted. Under the hood, AnvilBase listens to Postgres LISTEN/NOTIFY
and fans changes out to subscribed channels.
Subscribe (SDK)
const channel = db .channel('messages-feed') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) => { console.log('new message:', payload.new) } ) .subscribe()def on_insert(payload): print("new message:", payload["new"])
channel = ( db.channel("messages-feed") .on_postgres_changes(event="INSERT", schema="public", table="messages", callback=on_insert) .subscribe())db.channel('messages-feed').onPostgresChanges( event: PostgresChangeEvent.insert, schema: 'public', table: 'messages', callback: (payload) => print('new message: ${payload.newRecord}'),).subscribe();Event types
event | Fires on |
|---|---|
INSERT | new rows |
UPDATE | changed rows |
DELETE | removed rows |
* | all of the above |
The event payload
Each change delivers a payload with the new and/or old row and metadata:
{ "schema": "public", "table": "messages", "commit_timestamp": "2026-06-04T10:00:00Z", "eventType": "UPDATE", "new": { "id": "…", "body": "edited", "user_id": "…" }, "old": { "id": "…" }, "columns": [ { "name": "id", "type": "unknown" }, { "name": "body", "type": "unknown" } ], "errors": null}newis populated onINSERTandUPDATE;oldonUPDATEandDELETE.- How
oldis populated is described in Howold/neware populated below — it is a trigger-based feed, so a table’sREPLICA IDENTITYhas no effect on it. columnslists the row’s column names. AnvilBase’s trigger feed carries no column type metadata, sotypeis always"unknown"— treatcolumnsas a name list, not a schema.errorsis a JSON array of error markers, ornullfor a normal event. The only marker AnvilBase emits today is["payload_too_large"]— see below.
Oversized rows (payload_too_large)
Realtime change payloads are delivered over Postgres NOTIFY, which caps a
single message at ~7900 bytes. When a changed row exceeds that cap (wide JSON/
JSONB blobs, long text, large arrays), AnvilBase cannot send the full row
inline. Instead of dropping the event silently, it delivers a reference
event: new/old contain only the row’s primary key and errors is
["payload_too_large"]. Refetch the row by its primary key to get the current
contents.
{ "schema": "public", "table": "documents", "commit_timestamp": "2026-06-04T10:00:00Z", "eventType": "INSERT", "new": { "id": "3f7c…" }, "old": null, "columns": [ { "name": "id", "type": "uuid" } ], "errors": ["payload_too_large"]}Caveats:
- The reference event is still RLS-checked — you receive it only if you
could
SELECTthe row, exactly like a normal change. - A subscription with a
filteron a non-primary-key column will not receive the reference event: the filter can’t be evaluated without the full row, so it fails safe (drop, never leak). For wide-row tables, subscribe without a non-PK filter, or reconcile on thepayload_too_largemarker. - A table with no primary key cannot deliver the reference event to
anon/authenticatedsubscribers (there is no key to RLS-probe); onlyservice_rolesubscribers receive the flagged event for such tables.
Filtering
Restrict a subscription to rows matching a condition, using the same operator syntax as the REST API:
db.channel('my-orders') .on('postgres_changes', { event: '*', schema: 'public', table: 'orders', filter: `user_id=eq.${userId}` }, handle) .subscribe()Supported filter operators: eq, neq, in, gt, gte, lt, lte. The filter
is column=op.value (e.g. status=eq.shipped, priority=gte.3,
region=in.(eu,uk)).
DELETE filters are restricted to primary-key columns
A hard DELETE can’t be RLS-checked (the row is gone), so the content filter
is the only thing standing between the deleted row and the subscriber. To stop a
crafted predicate on a secret column (e.g. filter=email=eq.victim@x.com or
filter=balance=gte.1000000) from being used as a value oracle over deleted
rows, a non-service_role subscriber’s DELETE filter is restricted to
primary-key columns. A DELETE subscription whose filter references any
other column (or a table whose primary key can’t be resolved) receives no
event. A filter on a PK column (e.g. filter=id=eq.42) is still honored, and a
filterless DELETE subscription still delivers (the old payload is PK-only —
see below). service_role subscribers keep the unrestricted filter. For
filtered or sensitive deletes, prefer soft-delete (an UPDATE that sets a
deleted_at column), which flows through the fully RLS-checked UPDATE path.
How old/new are populated
AnvilBase’s change feed is trigger-based: provisioning installs an
AFTER INSERT OR UPDATE OR DELETE trigger that emits row_to_json(NEW) /
row_to_json(OLD). It is not WAL/logical-decoding, so a table’s
REPLICA IDENTITY has no effect on this feed — there is nothing to set, and
ALTER TABLE … REPLICA IDENTITY FULL changes nothing here.
INSERT—newis the full inserted row (RLS-gated);oldisnull.UPDATE—newis the full new row andoldis the full prior row, each RLS-gated independently: if the prior row is not visible to the subscriber under their own RLS,oldis stripped tonull(anUPDATEthat moves a row across the RLS boundary can’t leak its prior contents).DELETE— for non-service_rolesubscribersoldis reduced to the table’s primary-key columns only (the row is gone and can’t be RLS-probed, so only its key is revealed).service_rolesubscribers receive the fulloldrow.
Security
Realtime change events should be paired with RLS
on the table so a subscriber only receives changes to rows they’re permitted to
see. Subscribe with a user JWT (not the anon key alone) for per-user scoping,
and reserve the service_role token for trusted server-side consumers (e.g. a
worker that mirrors changes to a search index).
The change-feed RLS check evaluates against the same JWT claims your REST
queries see — including the enriched claims AnvilBase mints (email, aud,
app_metadata, user_metadata, session_id). So a policy written with
auth.email() or auth.jwt() -> 'app_metadata' ->> 'tenant' behaves
identically on postgres_changes and on REST for INSERT/UPDATE: those
events are delivered only if the same policy would let the subscriber SELECT
the row. (When the token’s JWT rotates mid-session via an
access_token refresh,
subsequent change events are checked against the new claims.)
DELETE cannot be RLS-checked — the row no longer exists, so there is
nothing to probe. Non-service_role subscribers receive only the primary key
of old, and a DELETE filter is restricted to PK columns (see
Filtering). Prefer
soft-delete (an UPDATE to a deleted_at column, which is RLS-gated) for
sensitive tables.
RLS-disabled tables are not delivered to non-service_role subscribers by
default. Because anon/authenticated hold a blanket SELECT grant, a table
with row-level security disabled would otherwise stream its every change to any
subscriber (including an anon-key holder). To expose an RLS-disabled table over
realtime deliberately, mark it realtime-public:
# CLIanvilbase realtime set-table reference --project <id> --rls-public true
# Management APIPUT /api/v1/projects/{id}/realtime/tables/reference{ "realtime_public": true }service_role subscribers are unaffected (they bypass the gate, matching REST’s
BYPASSRLS behavior).
Which tables emit events
Every public base table gets the realtime CDC trigger automatically at
creation — there is no publication to opt into (contrast Supabase’s
ALTER PUBLICATION supabase_realtime ADD TABLE). Zero-config: create a table and
its changes are live.
To take a table off the feed (to save write-path overhead or reduce exposure):
# CLI — disable / re-enable CDC for a tableanvilbase realtime set-table orders --project <id> --cdc falseanvilbase realtime set-table orders --project <id> --cdc true
# Management APIPUT /api/v1/projects/{id}/realtime/tables/orders{ "cdc_enabled": false }List every table’s realtime state (cdc_enabled, rls_enabled,
realtime_public):
anvilbase realtime tables --project <id># or: GET /api/v1/projects/{id}/realtime/tablesA CDC opt-out persists, but note the trigger is re-created if the table is
dropped and recreated (the auto-attach event trigger fires on CREATE TABLE);
re-apply the opt-out after recreating a table.
Delivery guarantees & limitations
The change feed is at-most-once with no replay. Events can be dropped, with no redelivery, when:
- the realtime service restarts or a per-project database listener reconnects;
- a project is deferred at the
ANVILBASE_REALTIME_MAX_LISTENERSceiling — such a project’s realtime simply appears dead (no events) until capacity frees up; - the RLS-gate query pool saturates (the gate fails closed and drops rather than stalls).
Realtime is a latency optimization, not a system of record. Pair it with a
periodic reconcile/refetch (and refetch on the payload_too_large marker) so a
dropped event self-heals. See
configuration for
ANVILBASE_REALTIME_MAX_LISTENERS, ANVILBASE_REALTIME_MAX_DB_CONNECTIONS, and
ANVILBASE_REALTIME_QUERY_POOL_SIZE.
Patterns
- Live feed / chat: subscribe to
INSERTon amessagestable filtered byroom_id. - Optimistic UI reconciliation: subscribe to
*and reconcile your local cache againstnew/old. - Cross-device sync: subscribe to
UPDATEon the user’s own rows (filter: user_id=eq.<id>). - Server-side CDC: a
service_rolesubscriber that pushes changes into a downstream system.
Unsubscribe
channel.unsubscribe()// or remove allawait db.removeAllChannels()Always tear down channels when a component unmounts to free connections.
Next: Broadcast & Presence.