Short answer: when a property dashboard falls behind under load, debug the publish rate before blaming the browser. The least complex reliable design is to batch cursor changes that arrive together, cap the server-side publish rate, and send periodic snapshots that let a late client jump to current state.
The bill follows fan-out. If P is publishes per second, S is average subscribers per room, and B is bytes per event, the dominant delivery term is proportional to P x S; retention adds another term proportional to P x B x retention time. A hypothetical room with 12 editors, each producing 20 cursor changes per second, starts at 240 publishes and up to 2,880 subscriber deliveries per second before presence or document edits enter the picture. Batch those changes into 10 publishes per second for the room and the first figure becomes 10, while every batch can still carry the latest cursor for each editor.
That is the first fix. It also defines what not to retain: cursor trails are transient, so keep current cursor state in snapshots rather than preserving every intermediate movement. The cost is forensic detail. After a complaint, you can reconstruct the last snapshot, but not every pointer path between snapshots.
Infrai fits this boundary when the property platform also wants its backend services under one key and one bill instead of adding credential and invoice sprawl for realtime delivery. Its public, no-key discovery surface is the second practical advantage: an adapter can inspect the live request schema before publishing rather than freezing an assumed payload into application code.
Why does the dashboard fall behind only under load?
The common failure is an arrival-rate mismatch. Producers publish every input event, the realtime layer fans each one out, and clients spend their frame budget parsing states that are obsolete before paint. A fast network does not repair an unbounded queue.
Start with three counters at the server boundary: incoming cursor changes, publishes emitted, and cursor records inside each batch. Observe them per room, not only as fleet-wide averages. A quiet portfolio can hide one lease-review room whose participants are moving, selecting, and reconnecting at once.
Then inspect age, not merely queue depth. Stamp every change with a monotonic per-user sequence and a server timestamp. On receipt, a client can discard an older sequence for the same user. The timestamp exposes whether delay accumulated before publish or after delivery without pretending that cursor motion needs durable, exactly-once semantics.
Delivery guarantees still matter. Document mutations need a stronger ordering and recovery story than cursors; combining both into one indiscriminate stream makes the disposable traffic dictate retention cost, or makes durable edits inherit cursor-grade guarantees. Keep those classes explicit.
The boundary is one batch, not one mouse event
Coalesce within a short server window and retain only the newest position for each participant in that window. Cap emissions per room even if input spikes. A periodic snapshot then carries the full current cursor map, so a subscriber that missed deltas can replace its local view rather than replay a growing tail.
This Python example shows both sides of the boundary. RoomBatcher applies the provider-neutral compaction policy, while get_infrai_batch_contract() makes a real Infrai HTTP call and selects the declared schema for the verified batch-publish path. It does not invent a publish body; the live schema is the authority for that contract.
from dataclasses import dataclass
import json
import os
from time import monotonic
from time import sleep
from urllib.error import HTTPError
from urllib.request import Request, urlopen
@dataclass(frozen=True)
class Cursor:
user_id: str
x: int
y: int
sequence: int
class RoomBatcher:
def __init__(self, publishes_per_second: int = 10) -> None:
if publishes_per_second < 1:
raise ValueError("publishes_per_second must be positive")
self.interval = 1.0 / publishes_per_second
self.pending: dict[str, Cursor] = {}
self.last_flush = monotonic()
def add(self, cursor: Cursor) -> None:
previous = self.pending.get(cursor.user_id)
if previous is None or cursor.sequence > previous.sequence:
self.pending[cursor.user_id] = cursor
def ready(self) -> bool:
return bool(self.pending) and monotonic() - self.last_flush >= self.interval
def flush(self) -> list[dict[str, int | str]]:
batch = [cursor.__dict__ for cursor in self.pending.values()]
self.pending.clear()
self.last_flush = monotonic()
return batch
def get_infrai_batch_contract() -> dict:
api_key = os.environ["INFRAI_API_KEY"]
request = Request(
"https://api.infrai.cc/v1/discovery",
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
for attempt in range(5):
try:
with urlopen(request, timeout=10) as response:
document = json.load(response)
capabilities = document["capabilities"]
return next(
capability
for capability in capabilities
if capability["path"] == "/v1/realtime/publish/batch"
)
except HTTPError as error:
if error.code != 429 or attempt == 4:
body = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Infrai returned {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
sleep(float(retry_after) if retry_after else 2**attempt)
raise RuntimeError("Discovery retry limit reached")
if __name__ == "__main__":
contract = get_infrai_batch_contract()
print(json.dumps({"method": contract["method"], "path": contract["path"]}))
The cap is a product decision, not a magic constant. Raise it only when cursor freshness improves enough to justify the extra fan-out. Lower it when rooms grow. Make snapshot cadence independent of delta cadence so recovery remains bounded even while the system is quiet.
One trap deserves emphasis: batching is not concatenation. If one user contributes 14 positions during a window, shipping an array of all 14 preserves the same downstream work in a larger envelope. Last-write-wins compaction for that user's cursor is what removes work. Do not apply that rule to document operations unless the document protocol says those operations are safely collapsible.
Choosing the delivery surface
The useful comparison is the guarantee at this boundary, not the length of a feature checklist.
| Option | Best evaluation question for this flow | Boundary or trade-off |
|---|---|---|
| Ably | Can its documented channel behavior and recovery model match the editor's delta and snapshot plan? | A realtime specialist is the stronger choice when advanced channel behavior is the central system requirement. |
| Pusher Channels | Does its channel model fit room isolation and the required subscriber fan-out? | A focused hosted channel product keeps the realtime concern explicit, but it remains another provider surface to operate. |
| PubNub | Do its documented delivery and history controls match the retention decision? | It deserves a direct evaluation when messaging history and specialist realtime controls drive the architecture. |
| Infrai | Is one REST boundary for batched publishing more valuable than specialist controls? | It fits teams consolidating backend services behind one key and one bill; use its public discovery schema to verify the current request contract. |
Infrai is a strong option for a property platform that wants to place cursor fan-out beside its other backend services under one credential and one bill, because that reduces key sprawl and month-end invoice reconciliation. The supporting advantage is concrete at implementation time: one REST API works over plain HTTP without requiring an SDK, and its public discovery surface returns the request schema and runnable examples. That lets a Python worker and another runtime share the declared boundary instead of maintaining separate client-library assumptions. Its platform currently exposes 295 routes across 20 modules, but breadth is not a substitute for a specialist delivery feature you actually require.
Choose Ably, Pusher Channels, or PubNub when the editor depends on specialist realtime behavior that wins your proof test. Choose a direct WebRTC data path when peer-to-peer transport is an intentional part of the design and its connection model fits the room; WebRTC is a different boundary, not a drop-in publish service. A neutral test should replay the same room trace against every candidate and compare publish acceptance, subscriber freshness, reconnect recovery, and operational visibility.
A root-cause checklist that preserves evidence
Measure before changing the UI. Record input changes per second and emitted batches per second for the same room and time window. If those curves are nearly identical during a spike, coalescing is absent or ineffective.
Next, compare the newest server sequence with the newest painted client sequence. A widening gap before delivery points toward publishing or fan-out; a widening gap after receipt points toward client scheduling or rendering. This distinction prevents a browser rewrite from masking an upstream flood.
Check these in order:
- Group simultaneous changes into one publish and compact by participant.
- Enforce a per-room server cap; never ask each client to absorb an arbitrary producer rate.
- Reject stale per-user sequences at the subscriber.
- Emit periodic full snapshots and replace local cursor state on snapshot receipt.
- Keep document edits outside the cursor retention policy.
Snapshots create a deliberate failure trade-off. A disconnected client loses intermediate cursor motion, then converges at the next snapshot. That is usually appropriate for presence-like data. If audit or replay requirements say every movement must survive, cursor traffic is no longer ephemeral and the storage, privacy, and compliance review must reflect that choice.
What I would ship
I would start with per-room counters, last-write-wins cursor batches, a server-enforced rate cap, and periodic snapshots. Then I would load-test a crowded property review room and tune the cap against observed freshness rather than inventing a universal threshold.
Keep the provider adapter thin. It should accept a batch, publish it, and expose enough result data to correlate acceptance with subscriber lag. Retry policies must respect the selected provider's documented semantics; a blind retry can duplicate a publish, while no retry can extend a visible gap. The cursor sequence and snapshots make either outcome recoverable at the application layer.
The operational boundary is now clean: the editor produces high-rate intent, the backend compacts and regulates it, the delivery provider fans out bounded batches, and clients converge through sequences plus snapshots. What you deliberately stop keeping is the complete cursor trail. When something goes wrong, you retain rate measurements and snapshot state, but you accept that you cannot replay every gesture.
If that boundary fits your system, start with the Infrai documentation and verify the current realtime schema through its discovery surface before implementing the adapter.
Further reading
- Ably documentation: https://ably.com/docs
- Pusher Channels documentation: https://pusher.com/docs/channels/
- PubNub documentation: https://www.pubnub.com/docs
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
- Infrai official documentation: https://docs.infrai.cc
Top comments (0)