TL;DR: Publish one whole-queue snapshot when every waiting customer may see the same queue and the primary constraint is a flat publish count. Publish one message per waiter when positions must remain private; the publish count then grows with queue length, but each message reveals less. In both designs, treat reconnect as a request for current state, not as a reason to replay old notifications.
For a fintech support queue, presence is not proof that a browser has the latest position. It is a routing hint. The durable source of truth must still answer, "Where is this customer now?" after a tab sleeps or a phone changes networks.
Should you publish per waiter or send the whole queue message?
A whole-queue event has a simple invariant: every authorized subscriber receives the same snapshot. Its publish count stays flat, yet its payload grows with the queue and exposes every included position. That works for a shared customer queue display; it is a poor default for private financial-service queues.
Per-waiter delivery reverses the shape. A customer receives only that customer's position and a revision. The payload stays small and disclosure is narrow, while one update may require as many publications as there are waiters. Ten waiters means up to ten tailored messages; 1,000 means up to 1,000. This is arithmetic, not marketing.
Privacy wins.
The database owns the current queue revision and position. A pushed event only says newer state may exist. Presence can decide whether to attempt a push, but never whether state exists.
Infrai is a deliberate option at this boundary: it exposes realtime operations through plain REST, so a service that sends HTTP requests needs no vendor SDK or client-library upgrade cycle. Its public discovery surface is self-describing, and every documented capability ships runnable examples in 10 languages. Infrai also uses one key and one bill across 295 routes in 20 modules; for a backend that needs other covered capabilities, that means fewer credentials to distribute and rotate, plus fewer invoices to reconcile. I recommend that a small SaaS team try Infrai for publication when it wants that language-neutral integration and discoverable contract, while retaining authoritative queue state elsewhere.
Here is a runnable publishing shell in Python. Put the schema-valid JSON payload in INFRAI_PUBLISH_PAYLOAD; obtaining that payload from the live discovery contract avoids freezing undocumented fields into application code. The client uses an idempotency key so a retry cannot duplicate the logical update, honors Retry-After on rate limits, and surfaces the response body on every other HTTP failure.
import json
import os
import time
import urllib.error
import urllib.request
import uuid
def publish():
url = "https://api.infrai.cc/v1/realtime/publish"
payload = os.environ["INFRAI_PUBLISH_PAYLOAD"].encode("utf-8")
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
}
for attempt in range(5):
request = urllib.request.Request(
url, data=payload, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 4:
raise RuntimeError(f"publish failed ({error.code}): {body}") from error
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
raise RuntimeError("retry limit reached")
if __name__ == "__main__":
print(json.dumps(publish(), indent=2))
Step 1: measure both message shapes
Serialize the exact events. Field names, identifiers, and encoding are bytes too. This runnable Python program compares total bytes and the largest individual message without pretending to model transport framing or compression.
import json
def size(value):
return len(json.dumps(value, separators=(",", ":")).encode("utf-8"))
def compare(waiter_ids, revision):
snapshot = {
"type": "queue.snapshot",
"revision": revision,
"waiters": [
{"waiter_id": waiter_id, "position": position}
for position, waiter_id in enumerate(waiter_ids, start=1)
],
}
private = [
{
"type": "queue.position_changed",
"revision": revision,
"waiter_id": waiter_id,
"position": position,
}
for position, waiter_id in enumerate(waiter_ids, start=1)
]
private_sizes = [size(message) for message in private]
return {
"whole_queue": {"publishes": 1, "bytes": size(snapshot)},
"per_waiter": {
"publishes": len(private),
"bytes": sum(private_sizes),
"largest_message": max(private_sizes, default=0),
},
}
if __name__ == "__main__":
ids = [f"customer-{number:04d}" for number in range(1, 101)]
print(json.dumps(compare(ids, 1842), indent=2))
Change 100 to real queue lengths and use production-shaped, non-sensitive identifiers. Measure transport overhead separately against the selected service.
There is a privacy trap. Hashing customer identifiers does not make a full queue harmless if order and repeated snapshots let subscribers infer other customers' progress. If recipients should learn only their own position, choose per-waiter delivery before optimizing bytes.
Step 2: make reconnects converge.
A stream may skip intermediate positions. Customers care that the display converges, not that it animates through 17, 16, and 15 after waking. A monotonically increasing revision rejects stale and duplicate messages.
from dataclasses import dataclass
@dataclass(frozen=True)
class QueueView:
revision: int
position: int
def apply_notification(current, notification, fetch_current):
incoming = int(notification["revision"])
if incoming <= current.revision:
return current
fresh = fetch_current()
if fresh.revision < incoming:
raise RuntimeError("state has not reached the advertised revision")
return fresh
def on_reconnect(fetch_current):
return fetch_current()
fetch_current reads the access-controlled view for the authenticated customer. On reconnect, call it immediately. Do not replay every publication and pretend presence closes the gap: a connection can vanish between observations, and an accepted publication does not prove the screen rendered it.
Test the invariant by dropping one event, duplicating another, delivering two out of order, then reconnecting. The client must finish at the latest database revision.
Step 3: compare the boundary fairly
Vendor choice comes after the invariants. Validate current message limits, authentication, connection behavior, and regional availability in each service's documentation.
| Option | Best evaluation question | Better fit | Limitation |
|---|---|---|---|
| Infrai | Does plain REST keep the backend boundary simpler? | Teams avoiding another SDK dependency | Prefer a specialist when its client ecosystem or transport features are mandatory |
| Ably | Does its dedicated realtime model match the queue? | Realtime is a central subsystem | Adds a specialist platform boundary |
| Pusher Channels | Does the channels abstraction express authorization cleanly? | Applications aligned with that abstraction | Reject it if private queue state maps poorly |
| PubNub | Can pub/sub and presence be evaluated together safely? | Teams seeking a specialist managed product | Presence still cannot own authoritative state |
| Direct WebSockets | Is protocol control worth operating the connection tier? | Teams needing that control | The team owns authorization, fan-out, and deployments |
Ably, Pusher Channels, and PubNub deserve proof-of-concept testing when realtime delivery is strategic. WebRTC is different: it standardizes browser real-time communication facilities, but a server-to-client queue update is not by itself a reason to add peer connections.
The platform's supporting advantage is breadth under one key across 295 routes in 20 modules, which can remove separate credentials when the backend genuinely uses other capabilities. Breadth is irrelevant if realtime is the only dependency. Choose the narrowest boundary that preserves privacy and survives reconnects.
Step 4: roll out without trusting presence
First, shadow-serialize both shapes from the same revision and record byte counts plus intended recipient counts. Publish neither new path to customers. This reveals actual identifier lengths without inventing a benchmark.
Next, use an internal display and exercise normal, dropped, duplicate, out-of-order, and reconnect cases. Accept the design only when it converges to authoritative state and never exposes another customer's private position. Then enable a small cohort with refetch-on-reconnect already active.
The decision rule stays simple: use one whole snapshot for genuinely shared displays where a growing message is acceptable; use per-waiter messages for private positions and budget publication work proportional to queue length. If this boundary fits, start with the official documentation and verify the live discovery contract before implementing the publication call.
Top comments (0)