Realtime streams and polling fallback
Three SSE endpoints, four event types, per-event authorization, and the flag that turns streaming off server-side.
ErmisAI has no push transport. Everything described on this page is server-sent events, and every SSE endpoint in the codebase is a server-side poller over Postgres. There is no pub/sub, no WebSocket, no message broker, and no database change feed. A connected client holds a streaming function open; that function re-reads the same data the REST endpoints read, on a fixed interval, and writes an SSE frame for anything it has not already seen on that connection.
Realtime state is not Redis-backed, whatever older documentation said. Alert history,
notifications, and the review and delivery event streams are platform_state rows in Postgres —
see Database schema and runtime state. Redis holds no application
state and takes no part in any realtime path.
/api/feed/events exists because it replaced a Supabase Realtime postgres_changes subscription on
editorial_story_drafts. The reasoning is in the route header at
src/app/api/feed/events/route.ts:16-23: tenant scoping is enforced server-side from the Clerk
session, so the browser needs no database client and no JWT template.
The three endpoints
| Route | Events emitted | Server poll | Keepalive | Self-close | Auth gate |
|---|---|---|---|---|---|
GET /api/notifications/events | alert.triggered, notification.created, review.status.changed, delivery.failed | 4 s | 15 s | 240 s | Session; per-event filtering |
GET /api/feed/events | feed.changed | 4 s | 15 s | 240 s | Session + resolvable tenant scope |
GET /api/stories/[storyId]/events | story.pipeline.updated | 7 s | 15 s | 290 s | Session + canEditStories |
All three return the same headers (src/app/api/notifications/events/route.ts:411-418):
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: nono-transform and X-Accel-Buffering: no are the anti-buffering pair: the first forbids
intermediaries from re-encoding the payload, the second disables nginx-style response buffering.
Drop either and a proxy may hold frames until the stream closes. Keep them on any new SSE route.
Only the story stream declares a function duration (export const maxDuration = 300,
src/app/api/stories/[storyId]/events/route.ts:23). The other two rely on the platform default and
simply close themselves at 240 s.
/api/notifications/events
The shared stream for the notification bell, the monitoring workspace, the editorial board, and two admin surfaces. One connection carries all four event types; each consumer subscribes to the subset it cares about.
Event payloads
The emit helpers in src/app/api/notifications/events/route.ts:137-201 are the contract. Client-side
they are re-validated by the zod schemas in src/lib/contracts/stream-events.ts.
// alert.triggered
{ id, ruleId: string | null, storyId, title,
severity: 'info' | 'warning' | 'critical',
channel: 'in-app' | 'email' | 'webhook',
deliveredAt, initial: boolean }
// review.status.changed
{ id, storyId, headline,
action: 'submitted' | 'approved' | 'rejected' | 'returned_to_compose' | 'resynthesized',
tenantScopeId: string | null, changedAt, initial: boolean }
// delivery.failed
{ id, deliveryId, tenant,
method: 'webhook' | 'email' | 'feed' | 'cms' | 'wordpress' | 'api',
responseCode: number, endpoint, createdAt, initial: boolean }
// notification.created
{ id, title, body,
type: 'alert' | 'system' | 'billing' | 'review' | 'note' | 'publication',
read: boolean, createdAt, initial: boolean }parseJsonWithSchema (src/lib/contracts/stream-events.ts:59-68) returns null on malformed or
schema-violating data rather than throwing, so a bad frame is dropped silently and the connection
keeps running.
Historical replay on connect
Before the poll loop starts, the connection replays the 20 most recent entries of each of alert
history, review status events, and failed deliveries, oldest first, each carrying initial: true
(route.ts:203-258).
notification.created is not replayed. The connect-time read of listNotifications only seeds
the seen-id set (route.ts:96), so a notification is emitted only when it appears on a later poll
tick.
Per-event authorization
Filtering happens server-side; the client never sees an event it is not entitled to
(route.ts:57-91, repeated for each tick at :283-293).
| Event | Who receives all tenants | Who receives their own tenant only |
|---|---|---|
review.status.changed | canAccessAdminSurface(appRole, 'review') — super_admin, editor | canReviewStories — tenant owner or admin |
delivery.failed | canAccessAdminSurface(appRole, 'deliveries') — super_admin, ops | everyone else |
alert.triggered | — | inherently: listAlertHistory is called with the caller's scope |
notification.created | — | inherently: listNotifications is called with the caller's scope |
A caller with neither review capability gets review.status.changed suppressed entirely — the route
substitutes Promise.resolve([]) for the read (route.ts:76, :278).
Tenant comparison is normalizeTenantId — trim plus toLocaleLowerCase('en') — on both sides
(route.ts:27-40). An event with a blank or missing tenantScopeId never matches a tenant filter.
listDeliveryFailedEvents() is called on every tick regardless of the caller's role
(route.ts:77, :279). It reads the global 500-entry array
(platform:admin:delivery-failed-events) and the route filters afterwards. Same for
listReviewStatusChangedEvents() when the caller can review. Both reads scale with connected
clients, not with tenants — there is no coalescing here, unlike the feed watermark.
The notificationsRestPolling short-circuit
If the flag is on, the route returns HTTP 204 with no body before doing any work
(route.ts:53-55). The rationale is in the comment above it: EventSource does not reconnect after
a 204, so the client's polling fallback takes over cleanly and the flag is authoritative server-side
rather than client-advisory.
The route calls isPlatformFeatureEnabled('notificationsRestPolling') with no tenant scope
(route.ts:53), so it reads the global default and ignores per-tenant overrides. The client reads
the effective value — global default plus per-tenant override — from GET /api/feature-flags
(src/app/api/feature-flags/route.ts:38-48). The two can therefore disagree. Behaviour still
converges, because a client that opens a stream against a 204 degrades to polling anyway, but a
per-tenant override alone does not stop the streaming function from being reachable.
/api/feed/events
Signals that the tenant's draft feed changed. It emits one event name, feed.changed, with the
watermark that moved:
{ latestUpdatedAt: string | null, draftCount: number }The watermark is a single aggregate over editorial_story_drafts for one tenant —
max(updated_at) and count(*)::int (src/lib/db/editorial-draft-repository.ts:378-393). The
stream compares both fields against the last values it emitted and stays silent when neither moved
(route.ts:94-99).
Reads are coalesced per tenant. readSharedDraftFeedWatermark
(src/lib/platform/feed/draft-feed-watermark.ts) caches the in-flight promise per tenant scope for
WATERMARK_SHARE_WINDOW_MS = 3500 — deliberately just under the 4 s poll interval — so N clients of
one tenant on one instance cost one query per poll window, not N. A rejected read is evicted from the
cache immediately so an error never sticks for a full window.
If the session has no resolvable tenant scope the route returns 204 (route.ts:34-38): there is
no feed to watch, the stream ends permanently, and the feed falls back to its normal fetch-on-demand
behaviour.
/api/stories/[storyId]/events
Streams pipeline-phase changes for one story. Gated by canEditStories, which is every tenant role —
owner, admin, member — plus the super_admin bypass (src/lib/auth/newsroom-roles.ts:143-145).
Connect sequence (route.ts:47-136):
getTenantStoryDetail(..., { lookupOnly: true }) — a snapshot read that deliberately does not
trigger feed synchronisation.
On a miss, getTenantStoryAccess decides whether the caller may see the story at all; a denial is
returned through createStoryAccessErrorResponse (see
API error and status reference). If access is fine, one full detail
lookup hydrates the snapshot. Still nothing → 404.
Writes retry: 1000 as the first frame, telling the browser to back off about a second before
reconnecting after a clean close. This is the only one of the three streams that sets a retry hint.
Emits the current phase immediately as story.pipeline.updated, then polls every 7 s and emits only
when phase or updatedAt changed.
Two guards are unique to this route:
pollInFlightprevents overlapping ticks when a lookup runs longer than the interval (route.ts:139-143).STREAM_MISS_LIMIT = 3— three consecutive lookups returning nothing close the stream (route.ts:152-158). A story deleted or moved out of scope stops the loop rather than polling forever.
The 290 s self-close exists because the Vercel function cap is 300 s; the header comment records that
hitting the cap previously spammed Vercel Runtime Timeout Error into the error feed every 60 s per
active viewer (route.ts:17-22).
Client transports
useNotificationStream
src/hooks/use-notification-stream.ts is the single client transport for the notification stream.
It owns both modes and hides the difference from consumers.
Prop
Type
Status machine: 'connecting' | 'streaming' | 'polling' | 'offline'.
restPolling: true→ statuspolling, one immediateonRefresh, then an interval. NoEventSourceis ever constructed.EventSourceundefined (SSR, or an environment without it) → straight topolling.onerrorstarts the poll timer as a fallback;onopenstops it (:181-198). Note the status expressioncurrent === 'streaming' ? 'streaming' : 'polling': once a connection has opened successfully, a later error keeps the badge on the "live" label while polling continues underneath. The 240 s self-close therefore looks like an uninterrupted live connection to the user.- The effect re-runs only on
eventsKey | restPolling | enabled | pollIntervalMs | skipInitialReplay(:210). Callbacks and the event array go through a latest-ref so a changed callback identity does not tear the stream down and reopen it.
Any new consumer must be correct under onRefresh alone. onEvent never fires in REST mode and
never fires during the SSE→poll fallback, so optimistic payload application is an optimisation, not
the source of truth.
useFeedRealtime
src/components/features/feed/hooks/use-feed-realtime.ts — 49 lines, deliberately minimal. It opens
EventSource('/api/feed/events'), listens for feed.changed, and calls onChange('UPDATE'). The
event kind is a stand-in: the watermark signal carries no insert/update/delete distinction and every
consumer treats the callback as "re-fetch the feed".
It has no polling fallback, ignores notificationsRestPolling, and does nothing when
tenantScopeId is null. StoryFeedWorkspace layers one narrow safety net on top: a single 4 s
re-fetch timer that runs only while bootstrapInProgress is true
(src/components/features/feed/StoryFeedWorkspace.tsx:201-213).
useStoryStream
src/hooks/use-story-stream.ts — its own transport, independent of the notification hook and of the
flag. Falls back to polling fetchStoryDetail every FALLBACK_POLL_INTERVAL_MS = 8000 on
onerror, and stops polling permanently on a 401, 403 or 404 (:101-103). Same four-state status
type, exported as StoryStreamConnectionStatus from src/lib/contracts/story-workspace.ts:10.
Which surface subscribes to what
| Surface | Component | Events | Options |
|---|---|---|---|
| Shell bell (desktop + mobile) | NotificationBadgeProvider | alert.triggered, notification.created, review.status.changed | — |
| Settings → Notifications | NotificationsWorkspace | same three | — |
/app/monitoring | MonitoringWorkspace | alert.triggered, delivery.failed | onEvent, enabled: !isBootstrapping |
/app/editorial | EditorialBoardWorkspace | review.status.changed | skipInitialReplay: true |
/admin/review | ReviewQueueWorkspace | review.status.changed | skipInitialReplay: true |
/admin/deliveries | DeliveryLogsWorkspace | delivery.failed | skipInitialReplay: true |
/app/feed | StoryFeedWorkspace via useFeedRealtime | feed.changed | separate transport |
| Story workspace | useStoryWorkspaceData → useStoryStream, badge in PipelineStatusTimeline | story.pipeline.updated | separate transport |
NotificationBadgeProvider exists specifically to own the single stream for the shell. The
desktop sidebar and the mobile header each render a bell and one is only CSS-hidden, so subscribing
inside the bell component opened two SSE connections per page
(src/components/layout/NotificationBell.tsx:20-24). The bells read an unread count from context.
Status labels by surface
Every badge is rendered from the same four-state status but each surface has its own strings.
| Surface | streaming | polling | connecting | offline |
|---|---|---|---|---|
/app/monitoring | Live alerts | Polling fallback | Connecting | Offline |
| Settings → Notifications | Live stream | Polling fallback | Connecting stream | Offline |
/app/editorial | Live | Polling | Connecting | Offline |
/admin/review | Live | Polling | Connecting | Offline |
/admin/deliveries | Live stream | Polling fallback | Connecting stream | Offline |
| Story workspace | Live | Polling | Connecting | Offline |
The feed has no transport badge at all. /admin/review uses the key stream.live where every other
surface uses stream.streaming — copy that key name wrongly and the badge renders a missing-message
error. All of these keys are covered by tests/locale-parity.vitest.ts, so a new one must land in
all ten locale files.
Monitoring overrides the displayed status while its initial data loads:
isBootstrapping ? 'connecting' : liveStreamStatus
(src/components/features/monitoring/MonitoringWorkspace.tsx:209). The underlying hook is offline
during that window because enabled is false.
The notificationsRestPolling flag
One of the five flags at /admin/flags, defined in src/lib/contracts/feature-flags.ts:40-44,
default false:
Forces the realtime notification/monitoring/review surfaces onto REST polling instead of the default Server-Sent Events stream. Off by default (SSE). Enable to avoid long-lived streaming functions; clients fall back to a 12s poll.
Enforcement is two-sided and both sides matter:
- Server — the route returns 204 so no stream can be established.
- Client —
useTenantFeatureFlagssuppliesrestPolling, so a client that has already loaded flags never attempts a connection in the first place.
useTenantFeatureFlags (src/hooks/use-tenant-feature-flags.ts) starts from
defaultTenantFeatureFlagMap — which has notificationsRestPolling: false — and silently keeps the
defaults if the fetch fails. So on first paint, and permanently if /api/feature-flags is failing,
clients attempt SSE. With the flag on they get a 204 and settle into polling; the outcome is right,
one wasted request per mount is the cost.
The flag does not affect /api/feed/events or /api/stories/[storyId]/events. Turning it on does
not eliminate long-lived streaming functions — it eliminates one of three.
See Feature flags and runtime controls for the flag store and the per-tenant override model.
Failure modes and gotchas
A poll tick must never reject unhandled. All three routes wrap the tick body in
void (async () => { … })().catch(…) and close the stream on error
(notifications/events/route.ts:260-266, 380-383). An unguarded rejection inside a long-lived
stream is an unhandled promise rejection, which is process-fatal by default — one transient
database error per connected client could take the instance down. Closing rather than continuing is
also deliberate: the client reconnects or falls back instead of hammering a known-broken backend
every 4 s.
Replay amplifies refetches on two surfaces. NotificationsWorkspace and
NotificationBadgeProvider pass neither onEvent nor skipInitialReplay, so each of the up-to-20
replayed alert.triggered frames and up-to-20 replayed review.status.changed frames calls
onRefresh — one GET /api/notifications per frame, on every connect and therefore every 240 s
reconnect. The three admin and editorial surfaces set skipInitialReplay: true and avoid it.
Every reconnect is a full cold start. The seen* id sets live in the closure of one connection
(route.ts:93-96). After the 240 s self-close the next connection rebuilds them from the same reads
and replays the same 20 historical entries. Nothing is deduplicated across connections; the client is
responsible for that, which is why MonitoringWorkspace dedupes alert history by id before merging.
delivery.failed in the monitoring UI is stream-only. The "Latest delivery failure" card is
populated exclusively from live events received while the page is open
(MonitoringWorkspace.tsx:54-59); it is never fetched on load, so a reload clears it until the next
failure arrives.
The feed stream has no fallback. If EventSource is unavailable or the connection fails
permanently, useFeedRealtime simply stops signalling and the feed only updates on user-initiated
fetches. There is no timer to catch that, outside the bootstrap window.
Two 204s mean two different things. On /api/notifications/events a 204 means "the operator
forced polling". On /api/feed/events it means "this session has no tenant scope". In both cases
EventSource fails the connection permanently rather than retrying, which is exactly the intent —
but do not read one as the other when debugging.
Streaming functions are billed as open invocations. Three concurrent streams per active tab, each re-reading Postgres on a 4–7 s cadence, is the actual load profile. The feed watermark is the only read that is coalesced.
Adding a new event type
Emit it. Add a typed emit helper next to the existing four in
src/app/api/notifications/events/route.ts:137-201, seed a seen* id set at connect, and add the
source read to both Promise.all blocks (connect and tick).
Decide the authorization filter server-side, in the route, following the
canAccessAdminSurface / tenant-scope pattern at route.ts:57-91. Never ship an event the client is
expected to filter.
Add the name to the NotificationStreamEvent union in
src/hooks/use-notification-stream.ts:26-30. The consumer arrays use
as const satisfies readonly NotificationStreamEvent[], so a typo fails typecheck.
Add a zod schema to src/lib/contracts/stream-events.ts and parse with parseJsonWithSchema in any
consumer that uses onEvent. Include initial: z.boolean().optional() if the event participates in
replay.
Make the consumer correct under onRefresh alone, then add onEvent if the optimistic path is worth
it. If the surface renders a transport badge, add its stream.* keys to messages/en.json and all
ten locale files or tests/locale-parity.vitest.ts fails CI.
Route behaviour for the two flag paths and the tenant filters is covered by
tests/notification-events-route.vitest.ts; the watermark coalescing and the 204 path are covered by
tests/feed-events-route.vitest.ts.
Database schema and runtime state
Where alert history, notifications and the global event arrays actually live.
Feature flags and runtime controls
The flag store, per-tenant overrides, and the rest of the /admin/flags surface.
Scheduled jobs and the queue subsystem
What produces the events these streams read — crons, the alert sweep, and the queue pipeline.
Route surfaces, proxy and auth gates
Why every route carries its own gate, and how session claims resolve to a tenant scope.
