Realtime feed
Server-sent events for the in-app feed - instant updates, with polling as the honest fallback.
The in-app feed polls every 30 seconds by default. That is cheap to run and works everywhere, but it means an in-app notification can be up to 30 seconds late, and ten thousand open tabs is a steady 333 queries a second that almost always return what the last one returned.
The realtime transport fixes both. The browser holds one long-lived stream at
GET {basePath}/events; when something changes the user's feed, the server
writes one event down it and the hook refetches.
Turning it on
There is nothing to turn on in the client - useNotificationFeed selects the
transport itself. On the server, the route exists by default:
export const push = betterPush({
database: drizzleAdapter(db),
providers: [webPush({ vapid })],
session: getSession,
// Defaults shown. `realtime: false` removes GET {basePath}/events entirely.
realtime: { maxDuration: "15m", pingInterval: "25s" },
});One instance needs nothing; several need a cache
With a single app process, realtime works with no extra infrastructure. The
moment you run two replicas, or move sending into a worker process, you
need cache: redis(...) - see below for why.
Why several instances need a bus
The hard part is not SSE. It is that the process running notify() is usually
not the process holding the stream. With two replicas the send lands on
instance A while the tab is attached to instance B. With
queue: dbQueue() the sending happens in the worker
process entirely.
So instances need a way to tell each other "user U has something new". That is the cache's pub/sub, one channel per user. The browser never touches Redis.
The event carries no content
An SSE frame - and the message on the bus behind it - is a signal, never content:
event: signal
data: {"reason":"created","notificationId":"018f...","}That is all of it. No title, no body, no data, ever. The client refetches page 1 through the ordinary feed endpoint and reuses the merge logic it already has.
Two things follow, and both are the point: nothing readable transits your Redis, and the HTTP endpoint stays the single definition of feed-item shape - there is no second serialization to keep in sync.
reason is "created" when a notification was written to the feed, and
"read" when a mark-read or read-all changed it. A send whose inApp channel
was suppressed publishes nothing, because nothing entered the feed.
What the client does
With transport: "auto" (the default) and a platform that has EventSource:
- Fetch the first page on mount, as before.
- Open the stream. Until the server's
readyframe arrives, polling continues at the normal interval - an open socket is not proof of a working stream, since a buffering proxy accepts one happily and delivers nothing. - On
ready, drop polling toidlePollInterval(default 5 minutes). Polling is slowed, never stopped: a stream that silently stops delivering must not strand the UI forever. - On a signal, wait ~250ms and refetch page 1. A burst of twenty notifications produces one fetch, not twenty.
- On error,
EventSourcereconnects by itself. After two consecutive opens that never reachready, give up permanently, close the stream, and go back to normal polling. - A hidden tab keeps its stream (it is nearly free, and gives an instant update on return); polling stays paused while hidden, exactly as before.
const feed = useNotificationFeed();
// "sse" only while a stream is genuinely live; "polling" otherwise.
<span>{feed.transport === "sse" ? "live" : "polling"}</span>;Options
| Option | Default | Meaning |
|---|---|---|
transport | "auto" | "auto" streams if it can, "sse" never falls back, "polling" never opens a stream |
idlePollInterval | 300000 | Safety poll interval while the stream is live |
pollInterval | 30000 | Poll interval when there is no live stream |
Serverless, and other places this will not work
A platform that cannot hold an open response for minutes cannot serve SSE. On Vercel functions, Cloudflare Workers with short limits, or behind a proxy that buffers responses, the stream either never delivers or is cut immediately.
That is handled rather than papered over: the client gives up after two failed
opens and reports transport: "polling", which is true. Everything keeps
working at the polling interval. If you know streaming is not available, set
realtime: false on the server and transport: "polling" on the client, and
skip the two wasted connection attempts.
React Native polls
@better-push/core/native does not open streams, and feed.transport always reads
"polling" there. React Native has no EventSource, and the polyfills that
exist cannot attach an Authorization header - which is exactly how native
authenticates. Rather than ship a transport that cannot carry native auth, the
realtime seam is simply absent on that platform and the shared core polls,
unchanged.
Operational details
- Bounded lifetime. A stream closes cleanly after
maxDuration(default 15 minutes) and the browser reconnects. Bounded connections make rolling deploys, proxy timeouts and platform limits ordinary events instead of incidents. - Keepalives. A
: pingcomment everypingInterval(default 25s) stops intermediaries from reaping an idle connection. - Proxy buffering. The response sets
X-Accel-Buffering: noandCache-Control: no-cache, no-transform. If you terminate with nginx or Caddy, make sure your config does not buffer or compress this route. - Per-user cap. One user may hold
rateLimit.maxStreamsconcurrent streams on one instance (default 5); beyond that the route answers 429. See Rate limiting. - Clean shutdown.
push.close()ends every open stream so clients reconnect elsewhere rather than hanging on a dying instance.
process.on("SIGTERM", () => void push.close());The studio keeps polling
The studio is not on this transport. It is an operator tool where a 10-second refresh is not a product problem, and giving it streams would add moving parts to the surface you look at when things are already going wrong.
Realtime interfaces
Prop
Type
Prop
Type