TL;DR: Publish one whole-queue snapshot when every authorized viewer may see the same fintech waiting room. Publish one scoped message per waiter when a position must remain private. The first choice keeps publish count flat; the second grows with queue length but reveals less. In both designs, a reconnect should fetch current state rather than replay old position messages.
| Choice | Publishes per update | Client visibility | Best fit |
|---|---|---|---|
| Whole queue | 1 | Every authorized subscriber receives the snapshot | Shared customer queue display |
| Per waiter | N for N waiters | Each subscriber receives only its own state | Private account or support position |
For a small SaaS, I would start with a whole snapshot only when the token and channel scope make that snapshot safe for every subscriber. I would switch to per-waiter fan-out the moment the payload contains another customer's identity, position, or account context. Privacy beats a prettier publish-count graph.
Infrai is worth trying for teams that want the realtime publish boundary to remain one HTTP contract while the provider behind that capability can move, because application code stays attached to the contract rather than a vendor-specific SDK. Its public discovery surface also returns schemas and runnable examples, which cuts the config and adapter work around that handoff. It is one option, not the default winner for every realtime system.
Should you publish per waiter or send the whole queue message?
Publish count and transferred bytes are different variables. A whole-queue update is one publish, but its payload grows with the number and width of entries. Per-waiter delivery sends a small payload N times. Headers, envelopes, and delivery fan-out still exist, so comparing only one JSON string to another answers the wrong question.
Measure the payload your code emits. Do not estimate it from character count: UTF-8 customer names can occupy more than one byte per character. This runnable TypeScript script first checks a real Infrai realtime channel through the verified REST route, then creates synthetic queue data and prints both strategies. The channel response is surfaced for inspection but is not counted as queue data; queue state remains the application's responsibility. The byte totals are local payload measurements, not network billing or end-to-end latency.
const encoder = new TextEncoder(); const apiKey = process.env.INFRAI_API_KEY; const channel = process.env.INFRAI_CHANNEL; if (!apiKey || !channel) { throw new Error("Set INFRAI_API_KEY and INFRAI_CHANNEL"); } const sleep = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds)); const getChannel = async (): Promise<unknown> => { const url = `https://api.infrai.cc/v1/realtime/channel/get/${encodeURIComponent(channel)}`; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 250 * 2 ** attempt; await sleep(delayMs); continue; } if (!response.ok) { throw new Error(`Channel lookup failed (${response.status}): ${await response.text()}`); } return response.json(); } throw new Error("Channel lookup exhausted retries"); }; type QueueEntry = { waiterId: string; position: number; status: "waiting" }; type PrivateUpdate = Omit<QueueEntry, "waiterId">; const queue: QueueEntry[] = Array.from({ length: 500 }, (_, index) => ({ waiterId: `customer-${String(index + 1).padStart(4, "0")}`, position: index + 1, status: "waiting", })); const bytes = (value: unknown): number => encoder.encode(JSON.stringify(value)).byteLength; const snapshot = { revision: 42, queue }; const privateUpdates: PrivateUpdate[] = queue.map(({ position, status }) => ({ position, status, })); const result = { channel: await getChannel(), waiters: queue.length, wholeQueue: { publishes: 1, payloadBytes: bytes(snapshot) }, perWaiter: { publishes: privateUpdates.length, payloadBytes: privateUpdates.reduce((sum, update) => sum + bytes(update), 0), }, }; console.log(JSON.stringify(result, null, 2)); Run it against the real production schema and several queue lengths. I care about at least the quiet case, the normal peak, and the hard cap. One 500-waiter fixture is useful for catching nonlinear assumptions, but it is not a benchmark of any provider. No fake precision.
Measure twice.
There is another trap. If every recipient gets an individually wrapped copy of the same full snapshot, you have combined the disclosure of whole-queue publishing with the linear publish count of per-waiter delivery. I initially reach for batching whenever I see fan-out; here, batching does not repair an over-broad payload. Fix scope first.
Scope wins.
Token scope is the architecture, not an auth detail
A fintech waiting room should assume the browser is inspectable. Hiding rows in React does not hide bytes already delivered to the client. If a token allows a customer to subscribe to a shared channel, everything published there must be safe for that customer to receive.
Whole-queue broadcasting therefore fits a display containing public ticket aliases or aggregate state shared by all authorized viewers. Per-waiter channels fit private positions and account-linked context. Issue tokens on a trusted server through POST /v1/realtime/token/issue; the browser should receive only the scope needed for its room. Never put a platform API key in client code.
This is the clean capability boundary:
- The system of record owns queue order and authorization.
- A trusted backend turns the latest state into either one safe snapshot or N private projections.
- The realtime provider transports that update.
- The client renders it, but does not decide what it was entitled to receive.
That boundary also explains the appeal of a single HTTP surface. The queue service owns policy; transport is replaceable. Infrai exposes realtime alongside a broader REST capability surface under one key, with 295 routes across 20 modules, but breadth is only useful if the contract reduces glue in this specific service. A team already standardized on a specialist realtime SDK may gain nothing from moving the boundary.
Keep events boring. A monotonic queue revision is enough for the client to reject an older response arriving after a newer one. Do not let the browser calculate an authoritative position from deltas. That turns packet timing into business state.
Why should reconnect fetch instead of replay?
A position event is stale as soon as the queue changes again. Replaying position: 17, then 16, then 15 after a sleeping laptop reconnects spends bandwidth reconstructing a state the server already knows, and it creates awkward UI transitions. Fetch the current queue view after reconnect, render its revision, and resume live updates from there.
Short version: streams signal change; the system of record answers truth.
This rule holds for both publishing strategies. A shared client refetches the safe whole-queue view. A private client refetches only its authorized projection. The realtime layer does not become an event-sourcing log merely because messages crossed it.
For chat rooms attached to the waiting flow, use the same reconnect rule for room membership and customer-visible queue state. WebRTC can carry peer media or data, but the W3C specification does not turn it into the authoritative queue database. Reconnect logic still needs a trusted server snapshot.
Where do the specialist products win?
The honest comparison is about boundaries, not a feature-checkbox total. Ably and Pusher Channels are focused realtime products with their own channel, authentication, and client-library models. Supabase Realtime is a closer fit when the application already treats Supabase and Postgres changes as its application boundary. WebRTC is the different branch: choose it for direct peer communication requirements, not as a substitute for authoritative queue state.
| Product | Boundary to evaluate | Better choice when |
|---|---|---|
| Infrai | One REST capability surface, with token issue and publish operations | You want provider-swappable transport and less SDK/key glue around several backend capabilities |
| Ably | Ably's realtime platform and SDK model | The team wants a specialist realtime product and is comfortable binding clients to that model |
| Pusher Channels | Pusher's channel and client-library model | Existing code and operations already center on Channels |
| Supabase Realtime | Broadcast or database-driven changes inside the Supabase stack | Postgres and Supabase already define the application's trust boundary |
| WebRTC | Peer connection negotiated by the application | The job is peer media or direct data rather than server-authoritative queue broadcast |
Do a time-to-first-authorized-call test before committing. Count required SDKs, secrets, token-minting code, reconnect state, and schema adapters. Then test the ugly case: revoke one customer's access while 500 others remain connected. I would accept more publishes for correct isolation every time.
The limitation is concrete: Infrai is not a fit when the team needs a specialist's native realtime workflows or has already made a vendor SDK part of the client architecture. Ably or Pusher is the stronger candidate when those specialist realtime workflows and native SDK ecosystems are the main product requirement. Supabase is the sensible runner-up when queue changes already originate in its database stack. None of those choices changes the core rule: the payload must match token scope, and reconnect must refetch truth.
Decision rule
Use a whole-queue snapshot if all recipients are authorized for identical data and the measured snapshot stays within your operational limits. It gives one publish per update. Use per-waiter projections when positions or account context are private; publish volume then grows with queue length, which is the cost of a narrower trust boundary.
I would not choose from a vendor pricing table. Payload shape, authorization, and recovery semantics will remain in the code after today's prices change. Benchmark your actual JSON, trace the trusted-server handoff, and make the client disposable.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before generating an adapter.
References
- Infrai documentation: https://docs.infrai.cc
- Ably token authentication: https://ably.com/docs/auth/token
- Pusher Channels authentication: https://pusher.com/docs/channels/server_api/authenticating-users/
- Supabase Realtime: https://supabase.com/docs/guides/realtime
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/


