--- title: "Broadcasting" description: "Publish typed browser updates through authorized channels, SSE, and memory or Redis providers, with reconnect reconciliation and transactional inbox hints." url: "https://www.beignetjs.com/broadcasting" markdown_url: "https://www.beignetjs.com/markdown/broadcasting.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "f27ffc691ada2988ce24a71558d3c529d53a9c1d" source_modified: true --- # 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 ```bash bun beignet make broadcast issues.changes bun install ``` The 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: ```bash bun beignet provider add broadcast-redis bun beignet make broadcast issues.changes bun install ``` The Redis provider supports ioredis 5 and 6. Set `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. ```ts 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`: ```ts import "@beignet/core/server-only"; import { createBroadcasting } from "@beignet/core/broadcasting/server"; import type { AppContext } from "@/app-context"; export const { defineChannelBinding, defineChannelRegistry } = createBroadcasting(); ``` 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: ```ts 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`: ```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`: ```ts import { createAuthHooks } from "@beignet/core/server"; import type { AppContext } from "@/app-context"; export const auth = createAuthHooks()({ resolve: ({ ctx }) => ctx.auth, }); ``` Then use a thin `app/api/broadcasts/route.ts` adapter: ```ts 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. Keep the host's request duration above the stream lifetime, with headroom for setup, authorization, and cleanup. See [Configure the connection lifetime](https://www.beignetjs.com/markdown/broadcasting.md#configure-the-connection-lifetime) when tuning renewal frequency. 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. ## 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`. ```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](https://www.beignetjs.com/markdown/outbox.md) 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. ```ts 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: ```ts 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: ```ts 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: ```ts 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. ## 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: ```ts 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 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`: ```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: ```ts 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. ## 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: ```ts 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: ```bash bun beignet make inbox --broadcast bun install ``` `make 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](https://vercel.com/docs/functions/configuring-functions/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](https://bun.sh/docs/runtime/http/server#idletimeout). 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.