Realtime events (SSE)

Copy Markdown View Source

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"]}
]
OptionDefaultMeaning
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_interval15_000ms 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:

StatusCodeWhen
401PGRST3xxJWT missing/invalid, same as the rest of the API. Checked first.
400BIER002No channel query parameter.
404BIER001A requested channel is not in events_channels.
406PGRST107Accept excludes text/event-stream.
405PGRST117Any 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 and EventSource reconnects 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 (:stop carries :duration, :delivered, and :reason — the chunk-write error that ended the stream, e.g. a client disconnect).

  • [:bier, :events, :notification] — per NOTIFY, with the :subscribers count reached.
  • [:bier, :events, :listener]:status of :connected / :disconnected; alert on this to spot gap windows.