Broadcasting
Use broadcasts to refresh issue lists, unread counts, job progress, and other
browser state when something changes. Define a typed channel, authorize each
subscription, and publish small hints through ctx.ports.broadcast. Browsers
receive those hints over one multiplexed Server-Sent Events (SSE) connection.
The database remains authoritative. Broadcasts are ephemeral: disconnected browsers miss events, duplicates are possible, and publication does not prove that a browser received anything. Refetch the channel's state after initial readiness and every reconnection. This capability does not provide durable replay, presence, offline writes, or collaborative conflict resolution.
Add broadcasting
bun beignet make broadcast issues.changes
bun installThe generator adds a browser-safe channel, a fail-closed authorization use
case and binding, lib/broadcasting.ts, server/broadcasts.ts, a streaming
endpoint, and a memory provider when the broadcast port is missing. Replace
the authorization denial with your application's current access checks.
Run bun install after generation to install the added provider dependencies.
It reuses auth.required() when your app already has the conventional
lib/route-auth.ts authentication helper. Otherwise the binding must assert
identity itself. It does not attach publications to unrelated mutations.
For multiple processes, select Redis before generating the first channel:
bun beignet provider add broadcast-redis
bun beignet make broadcast issues.changes
bun installSet REDIS_BROADCAST_URL and an application/environment-specific
REDIS_BROADCAST_PREFIX. If memory is already installed, replace its provider
registration with Redis. Every publisher and subscriber must use the same
Redis deployment and prefix.
Define the browser contract
Keep channel definitions in features/issues/channels.ts. Import this module
from both the browser and server; it must not import authorization or runtime
composition modules.
import { defineChannel } from "@beignet/core/broadcasting";
import { z } from "zod";
export const issuesChanges = defineChannel("issues.changes", {
params: z.object({ workspaceId: z.string().min(1) }),
events: {
changed: z.object({ issueId: z.string(), key: z.string() }),
},
});Parameters must parse to a flat record of strings. Parameter order does not
change channel identity. Each event has its own schema and a discriminated
payload type. Schemas must produce canonical JSON: plain objects, arrays,
strings, finite numbers, booleans, and null. Use timestamp strings instead of
Date. Transforms must be deterministic, idempotent, and free of side effects;
validation runs at producing and receiving boundaries. Parsed values are
validated again after JSON normalization. Invalid or unstable values reject
publication with BroadcastValidationError.
Only expose data that every authorized subscriber may read. Prefer identifiers and query invalidation over broadcasting entire database records.
Authorize every subscription
Bind the app context once in lib/broadcasting.ts:
import "@beignet/core/server-only";
import { createBroadcasting } from "@beignet/core/broadcasting/server";
import type { AppContext } from "@/app-context";
export const { defineChannelBinding, defineChannelRegistry } =
createBroadcasting<AppContext>();Keep authorization in features/issues/broadcasts.ts. The following binding
delegates to the app-owned use case generated by make broadcast. If the feature
already uses use-cases.ts, the generator adds authorization there and imports
it from ./use-cases instead:
import { defineChannelBinding } from "@/lib/broadcasting";
import { issuesChanges } from "./channels";
import { authorizeIssuesChangesUseCase } from "./use-cases/authorize-changes-broadcast";
export const issuesChangesBroadcast = defineChannelBinding(issuesChanges, {
authorize: async ({ ctx, params }) => {
await authorizeIssuesChangesUseCase.run({ ctx, input: params });
},
});That use case must assert the authenticated user, resolve current workspace
membership, and enforce any resource visibility policy. A client-supplied
workspace ID is a lookup input, not proof of access. Return normally to allow
the subscription; throw a catalog error to deny it. Public channels also need
an explicit binding with an intentional authorize callback.
Register bindings in server/broadcasts.ts:
import "@beignet/core/server-only";
import { defineChannelRegistry } from "@/lib/broadcasting";
import { issuesChangesBroadcast } from "@/features/issues/broadcasts";
export const channels = defineChannelRegistry([issuesChangesBroadcast]);Duplicate channel names are rejected. Authorization runs on every connection,
including scheduled renewals. Access can remain active until the current
stream ends: 60 seconds by default, or the configured maxLifetimeMs. Longer
connections reduce renewal and reconciliation frequency but increase the
interval between authorization checks. Use a shorter lifetime when your
revocation requirements need a smaller window.
Expose the stream
In a Next.js app, reuse your existing authentication helper. If the app uses
Beignet's session context and has no helper yet, create lib/route-auth.ts:
import { createAuthHooks } from "@beignet/core/server";
import type { AppContext } from "@/app-context";
export const auth = createAuthHooks<AppContext>()({
resolve: ({ ctx }) => ctx.auth,
});Then use a thin app/api/broadcasts/route.ts adapter:
import { createBroadcastRoute } from "@beignet/next";
import { auth } from "@/lib/route-auth";
import { getServer } from "@/server";
import { channels } from "@/server/broadcasts";
export const runtime = "nodejs";
export const maxDuration = 120;
export const { GET } = createBroadcastRoute({
server: getServer,
channels,
hooks: [auth.required()],
maxLifetimeMs: 60_000,
});The endpoint uses the server's context, hooks, metadata, and HTTP error
pipeline. Context fields added by route hooks are available to channel
authorization. Set metadata when your app hooks require route metadata.
For a Fetch runtime, import createBroadcastRoute from @beignet/web and
mount its GET handler at /api/broadcasts in your host. The web generator
exports createAppBroadcastRoute(server) from server/broadcast-route.ts;
pass your assembled Fetch server to that factory and mount its returned GET.
beignet.config.*
can change paths.broadcastRoute, paths.broadcasts, and
paths.broadcastingBuilder; the web profile defaults to
server/broadcast-route.ts. The endpoint is inspected separately from HTTP
contracts and is not added to OpenAPI as a replayable event API.
Configure the connection lifetime
maxLifetimeMs defaults to 60_000 milliseconds. It accepts positive safe
integers from 1 through 3_600_000 (one hour); false, zero, fractional
values, and larger or non-finite numbers are invalid. Every stream is bounded.
If your host permits five-minute requests, you can use a four-minute stream:
import { createBroadcastRoute } from "@beignet/next";
import { auth } from "@/lib/route-auth";
import { getServer } from "@/server";
import { channels } from "@/server/broadcasts";
export const runtime = "nodejs";
export const maxDuration = 300;
export const { GET } = createBroadcastRoute({
server: getServer,
channels,
hooks: [auth.required()],
maxLifetimeMs: 240_000,
});Choose a lifetime below the hosting request deadline, leaving time for server
setup, authorization, and cleanup. Declaring maxDuration does not override
your platform or plan's limits. The same maxLifetimeMs option is available
from @beignet/web.
The server includes maxLifetimeMs in subscription readiness messages. The
browser uses the first readiness message to configure one watchdog deadline,
measured from receipt of the streaming response, with a five-second grace
period for the server to close normally. Later subscription readiness messages
and heartbeats never extend that deadline. Heartbeat loss still closes a
connection after 55 seconds without stream data, and the initial readiness
timeout stays at 10 seconds.
Every readiness message must include a valid maxLifetimeMs. Missing or invalid
lifetime metadata blocks the connection's subscriptions instead of disabling
the watchdog. Before readiness, the independent ten-second timeout bounds the
connection setup.
Every renewal checks authorization again and calls onSync on readiness.
Even a planned renewal can miss events because broadcasting has no replay;
keep reconciliation enabled at every connection lifetime.
Reserve a connection slot
The new optional admit({ ctx, request, signal }) hook runs once per physical
multiplexed connection, after context/authentication hooks and before the SSE
response. It may return a release function (synchronous or asynchronous), or
nothing. Throw a catalog error to reject the whole HTTP request. HTTP 429 and
5xx responses are retryable; Retry-After is honored by the browser. Admission
does not replace independent authorization for each channel.
This example uses the existing LocksPort to implement an application-owned
three-slot policy. Declare locks: LocksPort in AppPorts and wire your lock
provider in server/providers.ts. Memory locks limit one process; distributed
limits need a shared, atomic lease store. Redis is one option, not a requirement.
Add BroadcastConnectionLimit to features/shared/errors.ts with status 429,
code BROADCAST_CONNECTION_LIMIT, and message Too many broadcast connections.
Place the policy in server/broadcast-admission.ts:
import type { LocksPort } from "@beignet/core/locks";
import { appError } from "@/features/shared/errors";
export async function reserveBroadcastConnection({
locks, userId, signal,
}: { locks: LocksPort; userId: string; signal: AbortSignal }) {
for (let slot = 0; slot < 3; slot++) {
signal.throwIfAborted();
const result = await locks.acquire(`broadcast:${userId}:${slot}`, {
ttlMs: 300_000,
waitMs: 0,
});
if (result.acquired) {
// Return ownership even if cancelled while awaiting acquisition.
return async () => { await result.lease.release(); };
}
}
throw appError("BroadcastConnectionLimit", { headers: { "Retry-After": "5" } });
}Register it on the endpoint in app/api/broadcasts/route.ts. This example
assumes the authenticated session is available in ctx.auth; requireUserId(ctx)
asserts it before acquisition:
import { createBroadcastRoute } from "@beignet/next";
import { auth } from "@/lib/route-auth";
import { getServer } from "@/server";
import { requireUserId } from "@beignet/core/ports";
import { reserveBroadcastConnection } from "@/server/broadcast-admission";
import { channels } from "@/server/broadcasts";
export const runtime = "nodejs";
export const maxDuration = 300;
export const { GET } = createBroadcastRoute({
server: getServer,
channels,
hooks: [auth.required()],
maxLifetimeMs: 240_000,
admit: ({ ctx, signal }) => reserveBroadcastConnection({
locks: ctx.ports.locks,
userId: requireUserId(ctx),
signal,
}),
});For Fetch hosts, use the same admit option on createBroadcastRoute from
@beignet/web in server/broadcast-route.ts, passing the assembled server and
its channel registry. The host still owns its request duration setting.
Beignet calls each acquired release function once on expiration, request/body
cancellation, provider closure, or failed stream setup. If only some channels
fail, their resources are released while accepted channels retain the connection.
If acquisition finishes after cancellation or the ten-second admission setup
timeout, its returned resource is released immediately. Pass signal to an
acquisition API when supported; the example checks it between attempts, then
returns a successful acquisition even if cancellation happened while awaiting
it. Throwing after acquiring without returning release would leak that resource.
Cleanup failures emit broadcast.cleanup-error without skipping other releases.
No application stream event listeners are needed. Each renewal runs fresh
admission and authorization. Distributed leases still need expiry because
abrupt process termination cannot run cleanup. Keep lease TTL above the stream
lifetime plus acquisition/setup/cleanup headroom; this example pairs a
five-minute lease with a four-minute stream. The hosting request limit must also
leave that headroom. Storage, slot count, expiry, and rejection policy remain
application-owned; the framework imposes no connection-limit store.
Publish after the authoritative change
Declare broadcast: BroadcastPort in AppPorts, importing the type from
@beignet/core/broadcasting/server. Wire the selected provider in
server/providers.ts and defer the port in infra/port-wiring.ts.
await ctx.ports.broadcast.publish(issuesChanges, {
params: { workspaceId: issue.workspaceId },
event: "changed",
data: { issueId: issue.id, key: issue.key },
});publish resolves when the provider accepts the message, even with zero
subscribers. Publishing before a database commit can make a browser refetch
old state. For required or retryable publication, record an ordinary job in
the same transaction as the mutation through
tx.jobs.dispatch(PublishIssueChangeJob, payload). Configure tx.jobs with
createOutboxJobDispatcher(transactionOutbox) and register the job with your
outbox drain and job execution entrypoint. Its handler publishes the hint.
See Outbox for transaction wiring and draining.
For optional hints, you can publish after the write and handle a publication failure without changing the successful mutation result. Await the attempt; do not leave required work running after a serverless request returns.
Subscribe and reconcile
Create one client per browser application session and share it between features. Construct it in browser lifecycle code, then close it when the user or workspace changes. It keeps connection identifiers in memory.
import { createBroadcastClient } from "@beignet/core/broadcasting/client";
import { issuesChanges } from "@/features/issues/channels";
const broadcasts = createBroadcastClient({ url: "/api/broadcasts" });
const subscription = broadcasts.subscribe(issuesChanges, {
params: { workspaceId },
onEvent: () => refetchIssues(),
onSync: () => refetchIssues(),
onStatusChange: status => showConnectionStatus(status),
onError: error => reportConnectionError(error),
});
// Component cleanup:
subscription.unsubscribe();
// Session/workspace cleanup:
broadcasts.close();Here workspaceId, refetchIssues, showConnectionStatus, and
reportConnectionError are application values and callbacks. onSync must
refetch all state covered by the channel, since events may have been missed
while disconnected. Callback failures are reported through onError without
breaking other subscribers. Unsubscribing suppresses later callbacks but
cannot cancel application work that a callback already started.
The client uses streaming Fetch, same-origin credentials by default, and
rejects redirects. Set headers to an async function when credentials need
refreshing for each request. Set credentials for your cross-origin cookie
policy and configure the matching server CORS policy. Never put credentials
in the URL. Identical channel subscriptions share one backend subscription
until the last observer unsubscribes.
React Query
Use createBroadcastQuerySubscription from @beignet/react-query to map
hints to ordinary TanStack filters. This example assumes existing listIssues
and getIssue HTTP contracts in the same issues namespace, with getIssue
using a key path parameter. rq is your app's createReactQuery(...) adapter,
and queryClient is the TanStack client used by the active UI:
import { createBroadcastQuerySubscription } from "@beignet/react-query";
import { rq } from "@/client";
import { listIssues, getIssue } from "@/features/issues/contracts";
import { issuesChanges } from "@/features/issues/channels";
const subscription = createBroadcastQuerySubscription({
client: broadcasts,
channel: issuesChanges,
params: { workspaceId },
queryClient,
invalidates: event => [
rq(listIssues).contractFilter(),
rq(getIssue).filter({ path: { key: event.data.key } }),
],
reconciles: [rq(listIssues).namespaceFilter()],
});Hints mark matching queries stale and coalesce refetches. If a hint arrives during a fetch, one later fetch reconciles it. Active queries refetch; inactive queries stay stale even when an earlier fetch completes after the hint. TanStack's disabled/static query behavior still applies. Teardown removes the subscription and cache listener.
Delay broadcast refreshes during saves
createBroadcastQuerySubscription already coordinates queries that are fetching.
Its optional refreshGate also coordinates pending writes. The new
createBroadcastMutationRefreshGate helper covers ordinary TanStack mutations;
the new BroadcastRefreshGate interface supports application write queues.
Existing subscriptions need no changes when this coordination is unnecessary.
For task queries, put the following in features/tasks/client/broadcasts.ts.
This example assumes browser-safe taskChanges in features/tasks/channels.ts
with a workspaceId parameter, and existing listTasks and saveTask contracts.
listTasks accepts a workspaceId query parameter. Use the returned mutation
options with useMutation, retaining your optimistic update/rollback callbacks:
import type { BroadcastClient } from "@beignet/core/broadcasting/client";
import {
createBroadcastMutationRefreshGate,
createBroadcastQuerySubscription,
} from "@beignet/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { rq } from "@/client";
import { taskChanges } from "@/features/tasks/channels";
import { listTasks, saveTask } from "@/features/tasks/contracts";
export function saveTaskMutationOptions(workspaceId: string) {
return rq(saveTask).mutationOptions({
mutationKey: ["tasks", workspaceId, "save"],
});
}
export function subscribeToTaskChanges(
client: BroadcastClient,
queryClient: QueryClient,
workspaceId: string,
) {
const taskQueries = rq(listTasks).filter({ query: { workspaceId } });
return createBroadcastQuerySubscription({
client, queryClient, channel: taskChanges, params: { workspaceId },
invalidates: () => [taskQueries],
reconciles: [taskQueries],
refreshGate: createBroadcastMutationRefreshGate({
queryClient,
mutations: { mutationKey: ["tasks", workspaceId] },
}),
});
}mutations accepts TanStack's mutationKey, exact, and predicate filters.
Key matching is inclusive unless exact: true. Give every related write the
same key prefix, including creates and deletes that affect lists or counters.
Include workspace or resource scope when appropriate. Unrelated mutations do
not block. Omitting a key/predicate deliberately selects all mutations.
The helper always checks pending mutations; status is not configurable.
Paused pending mutations (offline or waiting for a mutation scope) also block,
as do overlapping matching mutations until all settle. A custom predicate can
exclude mutations, but excluding paused writes can refresh over unsaved edits.
The gate has no timeout that overrides your blocking policy.
Combine mutations with an external write lock
Put this application helper beside the feature's broadcast query mappings.
Pass createTaskWriteRefreshGate({ queryClient, workspaceId, writeLock }) as
refreshGate in the subscription above. The external lock owns its state and
notifies after changes; it may inspect the query key to block only related
queries. Beignet owns listening, queuing, and cleanup:
import {
type BroadcastRefreshGate,
createBroadcastMutationRefreshGate,
} from "@beignet/react-query";
import type { Query, QueryClient } from "@tanstack/react-query";
// Application-owned write queue/lock. It can block different queries independently.
export interface TaskWriteLock {
isBlocked(query: Query): boolean;
subscribe(onChange: () => void): () => void;
}
export function createTaskWriteRefreshGate({
queryClient, workspaceId, writeLock,
}: { queryClient: QueryClient; workspaceId: string; writeLock: TaskWriteLock }): BroadcastRefreshGate {
const mutations = createBroadcastMutationRefreshGate({
queryClient,
mutations: { mutationKey: ["tasks", workspaceId] },
});
return {
isBlocked: (query) => mutations.isBlocked(query) || writeLock.isBlocked(query),
subscribe(onChange) {
const stopMutations = mutations.subscribe(onChange);
try {
const stopLock = writeLock.subscribe(onChange);
return () => { try { stopLock(); } finally { stopMutations(); } };
} catch (error) {
stopMutations();
throw error;
}
},
};
}Both event invalidations and readiness/reconnect reconciliation queue matching existing queries, deduplicated per query. A blocked query is neither invalidated nor refetched by the subscription. The gate is subscribed before checking state to avoid a missed wakeup and checked again immediately before queued work begins. Opening it schedules active queries for refresh and marks inactive queries stale. TanStack's enabled/static rules still control fetching. If it closes again, unprocessed work stays queued. A hint during an existing fetch remains pending for a subsequent refresh after that fetch settles.
The gate controls broadcast-driven refreshes only. Polling, manual refresh, mutation callbacks that invalidate queries, already-running requests, and optimistic cache updates remain application-owned. If all refresh sources must respect a write lock, apply that policy to those sources too.
Unsubscribe removes transport, query-cache, and gate listeners, discards queued
work, and prevents deferred callbacks from starting refreshes. It cannot undo
an already-started request. Gate checks that throw report through onError,
keep the query pending, and stay blocked until a later gate notification or
broadcast hint retries the check. They do not poll or retry themselves. If
subscribe throws, the subscription stays blocked: fix the cause, unsubscribe,
and create a new subscription. Custom gates must release partially acquired
listeners before throwing. Unsubscribe failures are reported while the other
listeners are still released.
Diagnose connection changes
onSync(info) and onStatusChange(status, info) receive
BroadcastConnectionInfo from @beignet/core/broadcasting/client. Callbacks
may omit the metadata argument when they do not need it.
createBroadcastQuerySubscription forwards status metadata and accepts an
optional onSync(info) observer; its reconciliation is still scheduled even
when that observer throws. This notification describes readiness, not completion
of queued or gated query refreshes. Do not refetch from that observer to bypass
the gate accidentally.
info.reason | What Beignet knows |
|---|---|
initial | The first physical connection attempt. |
planned-renewal | The server explicitly announced renewal and then ended the stream normally without further traffic. |
subscription-change | Adding or removing a distinct channel restarted the multiplexed connection. |
interruption | A timeout, transport/protocol failure, or retryable rejection interrupted the connection. |
unknown | An unexplained EOF, explicit resume, or browser wakeup has no more precise known cause. |
Readiness reports the reason for that physical connection; another observer
joining an already-ready channel sees the same information. Reconnecting status
reports the known reason for restarting. An elapsed lifetime alone never proves
planned renewal. Servers send an explicit, connection-wide renewal control frame
before closing normally. An unexplained EOF stays unknown. A renewal frame
followed by a stall produces interruption when a watchdog fires. Further traffic
after the frame makes a later EOF unknown, including an incomplete frame.
Reconciliation continues after every renewal because events have no replay.
Planned renewal is a lifecycle change, not an onError failure.
Routes use the existing broadcast instrumentation watcher for admission,
connection, and cleanup events. Server closure details report planned-renewal,
provider-disconnect, buffer-overflow, setup-failure, or unknown as known
locally; the server cannot infer why a browser cancelled. An optional
instrumentation sink on createBroadcastClient uses the existing
ProviderInstrumentationPort to record broadcast.connecting, broadcast.ready,
and broadcast.closed with browser reasons. These events contain safe counts,
durations, and reasons, never headers, credentials, payloads, or channel params.
Keep custom logging equally restrained.
Keep optimistic mutation state separate from remote query results while the mutation is pending. After settlement, reconcile using the returned version and invalidate affected queries. Broadcasting does not resolve conflicting edits or patch application caches automatically.
Exclude the initiating browser
When a mutation already updates its browser's cache, optionally exclude that
logical client from its hint. Merge broadcasts.getRequestHeaders() into
the typed HTTP client's dynamic headers callback. At the server boundary,
capture the header with an authenticated scope:
import { resolveBroadcastOrigin } from "@beignet/core/broadcasting/server";
const broadcastOrigin = resolveBroadcastOrigin({
headers: request.headers,
principalId: authenticatedUser.id,
tenantId: authenticatedWorkspace.id,
namespace: "my-app",
});Use the same resolver in createBroadcastRoute({ resolveOrigin }), or return
a previously resolved ctx.broadcastOrigin. Pass the captured object as
excludeOrigin when publishing. For a job, serialize it as broadcastOrigin
in the payload and forward it from the worker; never derive the origin from
the worker's service identity. broadcastOriginSchema validates a trusted,
previously captured origin at a Standard Schema boundary.
The X-Beignet-Broadcast-Client header contains a random in-memory client ID.
It grants no access. Missing or malformed values disable exclusion, as does
an anonymous principal. Matching includes the server-owned namespace,
tenant, and principal, so another user's client ID cannot suppress their
updates. The browser ID survives stream renewals; create a new client when
identity changes. Other tabs continue receiving hints. Default redaction
hides the header, broadcastOrigin, and excludeOrigin fields.
Notifications and inbox updates
defineBroadcastNotificationChannel({ channel, render }) from
@beignet/core/notifications adapts a notification into a typed publication.
Return undefined from render to skip delivery. Existing notification
preferences and independent channel retry behavior apply. A sent result
means provider acceptance, not an online recipient or a stored inbox row.
For a persistent inbox, generate the ordered application recipe:
bun beignet make inbox --broadcast
bun installmake inbox also enables this recipe when the app already declares a
broadcast port. When adding broadcasting to an existing generated inbox,
rerun make inbox; customized write paths require an explicit application
edit. The recipe authorizes the recipient, maps the inbox namespace for list
and count reconciliation, and commits each inbox write with an ordinary
publication job in the same database transaction. Mount the generated client
helper with the app's shared client and authenticated user ID.
Configure a reliable outbox drain. Retrying the publication job only publishes
the hint; it never repeats the inbox insert. Original notification delivery
retries still need application-owned deduplication where required. Separate
inbox and broadcast notification channels run independently and cannot
guarantee this ordering.
Limits and recovery
| Concern | Behavior |
|---|---|
| Connection lifetime | Defaults to 60 seconds; maxLifetimeMs accepts safe integers from 1 to 3,600,000 ms (one hour) |
| Browser watchdog | Required advertised lifetime plus 5 seconds from response receipt |
| Heartbeat | Every 25 seconds |
| Initial readiness | 10-second waits; timed-out subscriptions retry |
| Subscription count | 20 distinct channel/parameter pairs per client connection |
| Request size | 8 KiB of encoded query parameters |
| Event size | 64 KiB of canonical event data |
| Buffers | 1 MiB per stream/receiver queue; each receiver and each channel awaiting readiness also has a 128-event limit |
| Retry | Jittered exponential backoff capped at 30 seconds; retry deadlines survive subscription changes, and a longer Retry-After remains authoritative |
| HTTP 401/403/404 and other terminal 4xx | Block until explicit resume() or client recreation |
| HTTP 429 and 5xx | Retry automatically |
| Channel authorization failure | Denials block only that channel; 429/5xx failures remain retryable |
| Broker continuity loss | Close affected streams, reconnect, and refetch |
Unknown authorization failures return a sanitized retryable status, without
exception details. Malformed protocol messages block the affected connection's
subscriptions. Last-Event-ID is rejected: this protocol has no replay cursor.
Connection status is connecting, connected, reconnecting, blocked, or
closed; use per-subscription status when some channels are blocked.
connected means the subscription is ready, not that its query refresh has
finished. Server queue overflow closes the affected stream so the browser
reconnects and reconciles.
Deployment and operations
Memory broadcasting works only inside one process. Use Redis Pub/Sub when requests, workers, or replicas publish from different processes. Redis uses a shared publisher/subscriber pair per provider instance, bounded connection setup, and asynchronous subscription cleanup. A reconnect cannot recover missed Pub/Sub messages. Use separate prefixes per app and environment, restricted Redis credentials, and TLS where required by the deployment.
Serverless hosts must support streaming responses and outbound connections to Redis. Keep the stream lifetime below the host's request duration, with headroom for context creation, authorization, and cleanup. The Node.js example uses a 60-second stream and a 120-second host duration. Platform limits and plan availability still apply; see Vercel function duration. The Redis provider uses Node.js/ioredis APIs and is not an Edge adapter.
For Bun, configure an idle timeout above the 25-second heartbeat interval;
the Vite/Bun example sets idleTimeout: 60 in Bun.serve(...). Bun's default
10-second idle timeout otherwise closes quiet streams before their first
heartbeat. See Bun's timeout guidance.
Apply equivalent idle-timeout and buffering settings to any reverse proxy.
Long-lived streams consume host connections and memory. Measure concurrent connections, Redis connection counts, renewal/refetch traffic, latency, and cost in your deployment before increasing capacity. Avoid per-request worker loops and do not use memory as a cross-instance production transport.
Provider instrumentation uses the broadcast watcher for publications,
subscriptions, readiness, denials, temporary failures, and continuity loss.
Use client status callbacks to observe browser reconnections. Payloads and
origin IDs are omitted from broadcast-specific records. Keep application
logging equally selective. beignet routes, beignet map, beignet explain,
and beignet doctor inspect declarations and wiring; static checks cannot
prove policy correctness, transaction timing, or host suitability.