0010 — Server-Sent Events with durable resume, not WebSocket
Status: Accepted · 2026-08-07
Context
Section titled “Context”The alert wall must be live. The traffic is entirely server→client: every client→server action already has a REST endpoint, and Slack interactivity arrives over webhooks or Socket Mode. The real requirement is not “push” — it is correctness after a gap. An engineer’s laptop sleeps through an incident and must wake into a correct UI, not a stale one.
Decision
Section titled “Decision”One endpoint: GET /api/v1/stream, text/event-stream, with Last-Event-ID resume backed by a
durable ui_events table.
ui_events.seqis a monotonicBIGSERIALand is the SSE event id. On reconnect the browser sendsLast-Event-ID: Nautomatically; the server replaysseq > Nfrom the last 24 hours, then attaches live.- If the gap exceeds 10 000 rows or falls outside the retention window, the server sends a single
resyncframe and the client refetches. - Fan-out: each API pod holds one
pgxconnection issuingLISTEN oto_events. WritersNOTIFY oto_events, '<org_id>:<seq>'in the same transaction that wroteui_events. The payload is deliberately tiny — the 8 kB NOTIFY limit is a trap; the hub re-reads the rows. - Coalescing: at most one frame per connection per 250 ms per
(kind, resource_id). - Backpressure: a bounded ring buffer per connection; on overflow, drop and send
resync. Never block a writer for a reader. - Fallback: every stream-fed list endpoint also accepts
?since_seq=for pure polling, used when a corporate proxy kills SSE.
Consequences
Section titled “Consequences”- SSE is plain HTTP: it inherits the existing auth middleware, proxies, tracing and metrics, and
EventSourceis five lines in the browser. There is no second serialisation path and no custom reconnect logic. - Resume is free and correct. WebSocket gives none of this without building a replay log anyway — at which point the socket buys nothing.
ui_eventsis an hourly-partitioned table with 24-hour retention, and one more writer obligation inside domain transactions.- Solid’s fine-grained reactivity means an SSE-driven store update re-renders exactly one table row. That is Solid’s superpower and the reason it is in the stack.
- Client→server subscription changes require a reconnect with different query parameters. Acceptable.
Alternatives rejected
Section titled “Alternatives rejected”- WebSocket: bidirectional capability we do not need, plus connection state, heartbeats, custom reconnect and a second serialisation path. It becomes necessary only for collaborative editing or high-frequency client-initiated subscriptions — at which point it is an additive endpoint, not a rewrite.
- Long polling: more requests, worse latency, same amount of code.
- Polling only: simpler, and visibly worse for a live alert wall. Retained as the fallback, not the default.