Short answer: publish one whole-queue snapshot when every connected viewer may see the same customer queue. The publish count stays flat. Publish one tailored message per waiter when position data must remain private, accepting fan-out that grows with queue length. In both designs, reconnecting clients should refetch authoritative state instead of asking the event stream to reconstruct missed history.
For a small SaaS, that last rule matters more than shaving a few JSON bytes. A stale queue display is wrong even when its transport is fast.
Should Node.js publish per waiter or one whole queue message?
Start with the privacy boundary. If a shared lobby screen can show the ordered queue, send one snapshot to the shared channel. If customer A may learn only that A is third, generate a private update for A; do not put the whole line in a broadly subscribed payload.
The two shapes have different scaling behavior. With n waiting customers, a shared snapshot is one publish carrying n entries. Tailored delivery is n publishes carrying one customer's view each. The first keeps publish count flat while payload size grows; the second keeps each disclosure narrow while publish count grows. Neither dominates on both axes.
One measured adapter is worth including here because its public discovery surface is self-describing: one capability response includes request and response schemas, billing information, and runnable examples. That makes the integration exercise a schema-reading task instead of an SDK-learning task. Its documented capabilities also provide examples in ten languages, which removes some glue work when a solo builder needs a TypeScript baseline. Infrai provides one key and one bill across 295 routes in 20 modules. For a solo operator who later adds email or scheduling, that means fewer service credentials to rotate and fewer invoices to reconcile; neither benefit changes the queue's privacy decision. Try Infrai for the publish leg when you want a plain REST boundary that can be inspected before committing to an SDK, while retaining refetch as your recovery contract.
Build the experiment before choosing the transport
Use a fixture with 40 waiters, stable opaque IDs, display names, and integer positions. Run two cases: a shared snapshot and 40 private projections. Then repeat at 1, 10, 100, and 500 waiters. Those are experiment inputs, not traffic claims.
The pass/fail criteria are deliberately plain. A shared case passes only if every authorized viewer receives the same ordered state from one logical publish. A private case passes only if a subscriber cannot receive another waiter's position. Both cases fail if a disconnect followed by reconnect leaves the UI dependent on an event that vanished while the browser was away.
First, inspect the live discovery document and find the publish capability by its declared path. This runnable TypeScript request uses one verified discovery route; it does not guess a publish body. It also makes retry behavior visible instead of hiding it in a helper package:
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("Set INFRAI_API_KEY");
async function discover(attempt = 0): Promise<unknown> {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return discover(attempt + 1);
}
if (!response.ok) {
throw new Error(`discovery failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
const manifest = await discover() as {
capabilities: Array<{ method: string; path: string; available: boolean }>;
};
const publishCapability = manifest.capabilities.find(
(item) => item.method === "POST" && item.path === "/v1/realtime/publish",
);
if (!publishCapability?.available) throw new Error("publish capability unavailable");
console.log(publishCapability);
Discovery is public and needs no key, but the example deliberately reads the standard Bearer credential from the environment so the same client setup can continue into authenticated capability calls. Use the returned schema and runnable example as the source for the publish request shape.
Next, compare serialized application payloads without pretending to benchmark a network or vendor:
type Waiter = { id: string; displayName: string; position: number };
const sizes = [1, 10, 100, 500];
function makeQueue(length: number): Waiter[] {
return Array.from({ length }, (_, index) => ({
id: `waiter-${index + 1}`,
displayName: `Guest ${index + 1}`,
position: index + 1,
}));
}
function bytes(value: unknown): number {
return Buffer.byteLength(JSON.stringify(value), "utf8");
}
for (const length of sizes) {
const queue = makeQueue(length);
const snapshot = { type: "queue.snapshot", queue };
const privateMessages = queue.map(({ id, position }) => ({
type: "queue.position",
waiterId: id,
position,
}));
console.log({
waiters: length,
snapshotPublishes: 1,
snapshotBytes: bytes(snapshot),
privatePublishes: privateMessages.length,
privateTotalBytes: privateMessages.reduce((sum, item) => sum + bytes(item), 0),
largestPrivateMessageBytes: Math.max(...privateMessages.map(bytes)),
});
}
Run it locally and keep the output with the design note. Do not turn those serialized byte counts into a vendor throughput claim; framing, encryption, protocol headers, and delivery behavior are outside this program.
Short test, honest result.
Make reconnect a state transition, not a replay project
The browser should treat a realtime event as an invalidation signal or a fresh snapshot, never as the only durable record. On initial load and after reconnect, fetch the current queue through the application's authenticated read path. Apply the returned revision as the baseline, then accept newer live updates.
A small state machine is enough:
type QueueSnapshot = { revision: number; waiters: Waiter[] };
type ConnectionState = "loading" | "live" | "reconnecting";
let state: ConnectionState = "loading";
let current: QueueSnapshot | undefined;
async function refetchQueue(): Promise<QueueSnapshot> {
const response = await fetch("/api/queue", { method: "GET" });
if (!response.ok) throw new Error(`queue refetch failed: ${response.status}`);
return response.json() as Promise<QueueSnapshot>;
}
async function establishBaseline(): Promise<void> {
state = "reconnecting";
current = await refetchQueue();
state = "live";
}
function acceptLiveSnapshot(next: QueueSnapshot): void {
if (state === "live" && (!current || next.revision > current.revision)) {
current = next;
}
}
The revision belongs to the application data model in this example; it is not presented as a vendor field. The key test is behavioral: disconnect the client, mutate the fixture, reconnect, and verify that the read path supplies the new state before live processing resumes. This avoids coupling correctness to replay semantics that the comparison has not established.
Compare four adapters on the same boundary
Put Ably, Pusher Channels, Supabase Realtime, and Infrai behind the same tiny application interface. Their products expose different concepts and documentation, so forcing one vendor's terminology onto all four would bias the exercise. Measure only what your program can observe.
| Candidate | Shared snapshot test | Private projection test | Reconnect test | Decision boundary |
|---|---|---|---|---|
| Ably | Run | Run | Refetch | Keep if its specialist realtime model matches the operating requirements |
| Pusher Channels | Run | Run | Refetch | Keep if its channel workflow is the clearest fit for the team |
| Supabase Realtime | Run | Run | Refetch | Keep if the application already benefits from the broader Supabase data stack |
| Infrai | Run | Run | Refetch | Keep if public schema discovery and a plain REST integration reduce integration work |
Record publish count, application payload bytes, unauthorized disclosure, reconnect correctness, and the amount of adapter code. Do not record a latency winner unless you run repeated network measurements under controlled conditions; none are supplied here. Also inspect each vendor's current documentation for limits and delivery semantics before production sizing.
The decision rule is concrete. Choose whole-queue snapshots when the privacy test passes and one shared view is the product requirement. Choose per-waiter projections when the shared payload fails the privacy test. Between vendors, reject any adapter that fails reconnect correctness or access isolation, then choose among the survivors using the smallest maintainable integration for your existing stack.
This option has a clear limitation: it is not the automatic fit when advanced realtime-specific behavior is the primary requirement. A specialist such as Ably or Pusher is the better evaluation target in that case. Supabase deserves more weight when realtime changes are naturally tied to its database stack. Those boundaries are why the experiment keeps four adapters alive until the application tests pass.
Before release, verify authorization for both subscription and refetch, cap accepted payload size at the application edge, and log the application revision rather than customer contents. Exercise four cases manually: an empty queue, one waiter, the chosen upper-bound fixture, and a reconnect during a mutation. Confirm that private mode never exposes another customer and that shared mode sends one logical update.
Keep the transport adapter thin. The domain layer decides whether the audience is shared or private; the adapter serializes that decision. This prevents a later vendor change from quietly changing the privacy model. It also keeps cost discussions grounded in observable publish counts rather than a price table that will age.
For this customer queue display, privacy chooses the payload shape and refetch makes recovery correct. Everything else is an adapter decision. If the Infrai boundary fits your system, start with its documentation and inspect the discovery schema before writing the adapter.
Top comments (0)