Realtime
A generic per-channel live-event layer — publish() on the server, useRealtime() on the client, gated by a single deny-by-default authorization seam and fanned out by a Durable Object.
Realtime
The realtime layer pushes instant events to connected clients over WebSockets. It's a generic, per-channel fan-out primitive: any feature can publish an event to a channel, and every client subscribed to that channel receives it live — no page refresh, no polling. Notifications, the AI agent's proactive messages, and live campaign progress all ride on it.
The whole layer is two helpers and one Worker:
publish(channel, event)— server side,apps/web/lib/realtime.ts.useRealtime(channel, onEvent)— client side,apps/web/lib/use-realtime.ts(with a mirror in the Expo app).- The
RealtimeChannelDurable Object + the/realtime/*gate, hosted in the realtime Worker (apps/realtime) — the same Worker that hosts the AI chat agent.
For the deeper architecture, see the admin dev wiki at /admin/docs/realtime and /admin/docs/agents.
The channel model
A channel is a string of the form "<type>:<id>" — the type is everything before
the first colon, the id is everything after (so an id can itself contain colons).
Each distinct channel string maps to exactly one RealtimeChannel Durable
Object instance, and every socket connected to that instance receives every event
broadcast to it.
The template ships these conventional types:
| Channel | Who may subscribe |
|---|---|
user:<userId> | That user only (personal channel — proactive agent messages, the ping demo). |
notifications:<userId> | That user only (the notification bell's channel). |
campaign:<campaignId> | Admins only (live email-campaign send/open progress). |
doc:<id> / room:<id> | Denied by default — conventions for downstream shared channels. |
Why a Durable Object? On Cloudflare Workers each request can land on a different isolate, so an in-process subscriber map can't work — a
publish()on one isolate would never reach a socket held open on another. A named Durable Object is a single globally-unique instance that owns every socket for its channel, so the fan-out is correct in production. Idle channels cost nothing (WebSocket hibernation).
Publishing (server)
import { publish } from "@/lib/realtime";
// Fan an event out to every socket on the channel.
await publish(`user:${userId}`, { type: "demo-ping", ts: Date.now() });The event is any JSON object; by convention it carries a type discriminator so
the client can branch. Two rules matter:
- It's best-effort and non-throwing. A missed live push must never fail the
request. The caller has already persisted its source of truth (the notification
row, the campaign counters); the WebSocket fan-out is purely an
instant-delivery optimization. If the shared secret is unset or the push fails,
publish()logs and returns — it never throws. Don't rely on its return for correctness. - Always
awaitit. In the Workers runtime a promise left running after the response is returned can be cancelled before it completes, silently dropping the push.await publish(...)(or hand it toctx.waitUntil) so the request stays alive until the event is sent.
Under the hood, publish() prefers the REALTIME service binding in production
(a direct Worker-to-Worker call — a same-zone public fetch would loop and return
522) and falls back to an HTTP POST under local next dev. Either way it hits the
Worker's /realtime/internal/publish route, authenticated with the shared
AGENTS_INTERNAL_SECRET.
Subscribing (client)
import { useRealtime, type RealtimeEvent } from "@/lib/use-realtime";
useRealtime(`user:${userId}`, (event: RealtimeEvent) => {
if (event.type === "demo-ping") {
// update local state — no refetch, no page reload
}
});The hook opens a WebSocket to /realtime/<channel> and calls onEvent for each
event. Notable behavior:
- Pass
nullas the channel to subscribe to nothing (e.g. while the user id is still loading). The hook tears down cleanly on unmount or channel change — no leaked sockets or timers. - The socket stays warm. The hook sends a
{ type: "ping" }heartbeat every 25s; the matchingpongis swallowed, so youronEventnever sees ping/pong. (This is why events use names likedemo-ping, not a literalping.) - Reconnect is automatic with capped exponential backoff (1s → 30s). A
1008close (session rejected/expired) stops reconnection — retrying would just be refused again. - It reconnects immediately when the tab returns to the foreground.
The same hook and RealtimeEvent type exist in the Expo app with identical
signature; only the transport plumbing differs.
Channel authorization
Every subscription is gated before any Durable Object is touched. The
/realtime/* route in apps/realtime/src/index.ts validates the Better Auth
session cookie (a non-101 response refuses the handshake), then calls
authorizeChannel(parsed, user) from apps/realtime/src/channel/routing.ts —
the single authorization seam, a pure, synchronous, deny-by-default policy:
// apps/realtime/src/channel/routing.ts
export function authorizeChannel(
parsed: ParsedChannel,
user: { id: string; role?: string },
): boolean {
switch (parsed.type) {
case "user":
case "notifications":
return parsed.id === user.id; // own channel only
case "campaign":
return user.role === "admin"; // admin-only, fail-closed
default:
return false; // unknown type → denied
}
}user:/notifications:— a user may subscribe only to their own id.campaign:— admin-only. Campaigns are a global admin surface, so any admin may watch any campaign's live progress; the gate reads the Better Authrole(synced from the app's admin flag) and fails closed when it's absent or non-admin, so recipient data is never fanned out to a non-admin socket.- Every unknown type is denied. The template ships no membership model for
doc/room, so the safe default is to refuse.
Adding a shared channel (downstream)
Shared channels are a downstream extension, and routing.ts is the only file
you touch. Add a case with your membership check:
case "doc":
return userCanAccessDoc(user.id, parsed.id);If the check needs a database or the web API, swap the synchronous authorizer for
an async one — the signature is designed to be replaced with
(parsed, user, env) => Promise<boolean>, which the worker gate awaits before
forwarding to the Durable Object. Keep this the only place that decides who may
subscribe to what.
Worked examples in the template
The agent is just one publisher among several — it has no special status in the realtime layer. Real publishers:
- Notifications (
apps/web/lib/notifications.ts) — the production reference.createNotification()writes the row to the database (source of truth), then publishes anew-notificationevent tonotifications:<id>. The notification bell subscribes withuseRealtimeand prepends the row live. See Notifications. - Agent proactive delivery — the AI agent publishes an
{ type: "agent-message" }event to the user'suser:<id>channel; if no socket receives it, it falls back to an unread notification. See AI chat. - Live campaign progress (
apps/web/lib/campaign-realtime.ts) — publishescampaign-progressandcampaign-openedevents tocampaign:<id>so the admin campaign detail page updates without a manual refresh. See Campaigns.
The ping demo
The RealtimeDemoCard on the /app dashboard is a runnable end-to-end example
with no database row in the middle, so a working ping proves the whole path:
"Send ping" button
→ POST /api/realtime-demo/ping
→ publish("user:<callerId>", { type: "demo-ping", ts })
→ realtime Worker → RealtimeChannel DO broadcast
→ useRealtime("user:<callerId>") fires → counter ticks (no reload)The route publishes to the caller's own user channel — exactly what
authorizeChannel permits — so the demo needs no custom authorizer. Use it as the
copy-paste starting point for a new realtime feature, and notifications as the
production reference.
Configuration
| Variable | Purpose |
|---|---|
AGENTS_INTERNAL_SECRET | Shared secret for the server-to-server publish call. Must match on the web app and the realtime Worker. Unset → publish() no-ops (logs and returns). |
The realtime Worker is the same one that hosts the AI chat agent, so if chat is running, realtime is already wired.
Related
- AI chat — hosted in the same Worker; uses this layer for proactive messages.
- Notifications — the production publisher.
- Campaigns — live send/open progress over an admin-only channel.
Response Caching
Two independent caching layers — Cloudflare Workers Cache for public HTTP responses (with a hard credential bypass) and OpenNext's R2/D1 incremental cache for Next.js ISR/data.
Admin panel
The /admin dashboard — user management, RBAC roles, audit log, impersonation, and the database browser, plus how a user becomes an admin.