Bier can bridge PostgreSQL's LISTEN/NOTIFY to browsers and other HTTP
clients as Server-Sent Events.
Anything in your database that runs NOTIFY — a trigger, a function, plain
application code — becomes a live event stream, with no extra service to
deploy. PostgREST has no equivalent.
Configuration
The feature is off by default. Enabling it means allowlisting channels:
children = [
{Bier,
name: MyApp.Bier,
router: [port: 4040, scheme: :http],
events_channels: ["orders", "chat"]}
]| Option | Default | Meaning |
|---|---|---|
events_channels | [] | Channels clients may subscribe to. Empty = feature disabled. |
events_path | "events" | Reserved top-level path segment. Change it if you expose a relation named events. |
events_heartbeat_interval | 15_000 | ms of silence before a : keepalive comment is sent. |
While enabled, GET /<events_path> no longer resolves as a relation — pick
a different events_path if that collides with one of your tables.
Subscribing
One connection can multiplex any number of allowlisted channels; each NOTIFY
arrives with the channel name in SSE's native event: field and the payload
verbatim in data:.
curl -N "http://localhost:4040/events?channel=orders,chat"
event: orders
data: {"id": 42}In the browser:
const es = new EventSource("/events?channel=orders,chat");
es.addEventListener("orders", (e) => console.log(JSON.parse(e.data)));
es.addEventListener("chat", (e) => console.log(e.data));Emit events from SQL:
NOTIFY orders, '{"id": 42}';pg_notify is the parameterizable form, which is what you want when the
payload is built from a row — inside a trigger function, for instance:
SELECT pg_notify('orders', json_build_object('id', new.id)::text);Authentication
The endpoint uses the instance's standard JWT gate: when jwt_secret or
db_anon_role is configured, subscriptions are authenticated exactly like
API requests. Because the browser EventSource API cannot set headers, this
endpoint also accepts the token as a query parameter — the Authorization
header wins when both are present:
const es = new EventSource(`/events?channel=orders&access_token=${jwt}`);Note that query strings tend to end up in server logs; prefer the
Authorization header for non-browser clients.
The token is verified once, at connect time — the SSE connection is then
held open indefinitely and is not re-checked against the token's exp.
A long-lived stream can therefore outlive the JWT that opened it: a token
that expires five minutes after connecting does not cause the stream to
close five minutes later. Bounding stream lifetime by exp (closing or
requiring reauthentication when the token expires) is possible future
hardening; it is not currently implemented.
Errors
Errors use the PostgREST envelope with Bier-specific codes. Auth is checked before channel validation, so on a JWT-protected instance a tokenless request is always 401 regardless of whether the requested channel exists:
| Status | Code | When |
|---|---|---|
| 401 | PGRST3xx | JWT missing/invalid, same as the rest of the API. Checked first. |
| 400 | BIER002 | No channel query parameter. |
| 404 | BIER001 | A requested channel is not in events_channels. |
| 406 | PGRST107 | Accept excludes text/event-stream. |
| 405 | PGRST117 | Any method other than GET or OPTIONS. |
OPTIONS /<events_path> never reaches this endpoint's handler at all: the
router's generic OPTIONS handling (the same one every relation gets)
answers it with 200 (and, when a relation of that name exists, an
Allow header) before dispatch reaches Bier.Events, so CORS preflight
requests against the events endpoint work normally.
Delivery semantics and limits
Be aware of what LISTEN/NOTIFY actually guarantees — Bier does not
pretend otherwise:
- At-most-once. Events fired while a client (or Bier's listener
connection) is disconnected are lost. Clients receive a
retry:hint andEventSourcereconnects automatically, but nothing is replayed. - 8000-byte payloads. Postgres rejects larger NOTIFY payloads. For big rows, notify a key and fetch the row through the regular API (see the tutorial for the pattern).
- Ordering follows Postgres's notification queue per connection.
- Slow clients buffer in their own mailbox. Delivery to each subscriber is a plain Erlang message send; if a client reads slower than events arrive, the backlog piles up in that subscriber's Bandit connection process mailbox. This is unbounded today and affects only that one subscriber — the listener process and every other subscriber are unaffected. A mailbox-size guard (dropping or disconnecting a subscriber that falls too far behind) is possible future hardening; it is not currently implemented.
Telemetry
[:bier, :events, :subscribe, :start | :stop]— one span per SSE connection (:stopcarries:duration,:delivered, and:reason— the chunk-write error that ended the stream, e.g. a client disconnect).[:bier, :events, :notification]— per NOTIFY, with the:subscriberscount reached.[:bier, :events, :listener]—:statusof:connected/:disconnected; alert on this to spot gap windows.