better-push
Operate in production

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:

src/push.ts
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:

  1. Fetch the first page on mount, as before.
  2. Open the stream. Until the server's ready frame 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.
  3. On ready, drop polling to idlePollInterval (default 5 minutes). Polling is slowed, never stopped: a stream that silently stops delivering must not strand the UI forever.
  4. On a signal, wait ~250ms and refetch page 1. A burst of twenty notifications produces one fetch, not twenty.
  5. On error, EventSource reconnects by itself. After two consecutive opens that never reach ready, give up permanently, close the stream, and go back to normal polling.
  6. 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

OptionDefaultMeaning
transport"auto""auto" streams if it can, "sse" never falls back, "polling" never opens a stream
idlePollInterval300000Safety poll interval while the stream is live
pollInterval30000Poll 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 : ping comment every pingInterval (default 25s) stops intermediaries from reaping an idle connection.
  • Proxy buffering. The response sets X-Accel-Buffering: no and Cache-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.maxStreams concurrent 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

On this page