DEV Community

mT41vB6
mT41vB6

Posted on

Courier Room Costs When Pushing Beats Polling Above a Handful of Viewers

TL;DR: Pushing beats polling above a handful of viewers on a logistics dashboard because the bill follows room changes instead of a repeated read interval. Keep one event stream per signed-in dashboard, fan out room-scoped presence changes, and reserve polling for repair. Short-lived, scoped room tokens limit who can join; they do not make presence accurate by themselves.

Start with the bill. A polling design pays for request admission, authentication or session lookup, a presence read, response serialization, and network headers on every interval. Its rough request rate is viewers / interval_seconds. Sixty dispatchers polling every two seconds produce 30 requests per second, or 1,800 per minute, even during a quiet minute. A ten-second interval drops that to 360 requests per minute, but a departure can remain invisible to a dashboard for almost the whole interval.

The dominant term is usually repeated reads when presence changes less often than clients ask for it. Suppose the same 60 dashboards observe a room that produces 12 relevant joins, leaves, or lease expirations in one minute. Full fanout delivers at most 720 small event messages for that room in the simple model, plus connection maintenance. The numbers are an explicit capacity-planning example, not a benchmark: substitute measured change rate, payload size, connection churn, and storage operations from your own system.

Quiet rooms matter.

That is the useful change: make delivery proportional to state transitions. Do not confuse fewer application messages with free infrastructure. Long-lived connections consume file descriptors and memory, reconnects cause admission work, and fanout still scales with recipients.

Why Does Pushing Beat Polling Above a Handful of Viewers?

Polling has two clocks. The browser samples on its interval, while the server changes state whenever a courier joins, disconnects, or loses a lease. Those clocks are unrelated. A two-second interval therefore creates a visibility lag of up to roughly two seconds before network and processing delay; lengthening the interval to reduce reads lengthens that stale window too.

A video room adds a second distinction that matters operationally: authorization and presence are separate facts. A signed token can establish that a courier may join a particular room until an expiry time. It cannot prove that the browser is still connected, that media negotiation succeeded, or that the last disconnect event reached every observer. Treating token validity as presence produces convincing but wrong green dots.

That distinction is easy to miss.

WebRTC also does not remove the need for this control plane. The WebRTC specification defines browser-side real-time communication interfaces, but signaling is outside its scope. Your service still has to authenticate room joins, exchange whatever signaling data the application requires, and publish an authoritative presence view.

Presence accuracy needs a definition. For a dispatch dashboard, I would define a member as present only while the server has an active room membership lease tied to a live connection generation. A graceful leave can end it immediately. A broken network cannot, so the lease expires after a chosen heartbeat timeout. That timeout is a deliberate trade-off: shorter detects absence sooner but raises heartbeat and false-expiry pressure; longer reduces churn but leaves stale operators on screen.

Model the cost before choosing a transport

A small calculator makes the decision reviewable. It should keep polling reads, pushed deliveries, and retained bytes separate because they scale for different reasons.

from dataclasses import dataclass


@dataclass(frozen=True)
class RoomLoad:
    viewers: int
    poll_interval_seconds: float
    changes_per_minute: int
    event_bytes: int
    retained_events: int


def estimate(load: RoomLoad) -> dict[str, float]:
    polling_requests_per_minute = (60 / load.poll_interval_seconds) * load.viewers
    pushed_deliveries_per_minute = load.changes_per_minute * load.viewers
    retained_bytes = load.event_bytes * load.retained_events
    return {
        "polling_requests_per_minute": polling_requests_per_minute,
        "pushed_deliveries_per_minute": pushed_deliveries_per_minute,
        "retained_bytes": retained_bytes,
    }


example = RoomLoad(
    viewers=60,
    poll_interval_seconds=2,
    changes_per_minute=12,
    event_bytes=240,
    retained_events=2_000,
)
print(estimate(example))
Enter fullscreen mode Exit fullscreen mode

This deliberately omits prices. Capacity planning is more stable when it starts with units: admitted requests, concurrent connections, messages delivered, bytes transferred, and bytes retained. Apply the rates from your own runtime, queue, and network contract afterward.

The crossover is not a magic viewer count. In the simplified model, polling work grows as V / I, while pushed delivery grows as C * V, where V is viewers, I is the interval in seconds, and C is changes per second. Push gains ground when C is lower than 1 / I, especially when a poll performs a database read. Connection overhead and batching move the real crossover, so load-test it with the expected room distribution rather than one average room. For the 60-viewer example, the decision review should show both sides: 1,800 polling reads per minute at a two-second interval versus 720 event deliveries per minute at 12 changes. Then vary changes from zero through the busiest credible dispatch handoff and include reconnect admission. If push stops winning under the busy case, that is useful capacity evidence, not a reason to hide the case behind an average.

Watch the tail. A warehouse handoff may put many supervisors into one incident room while most rooms have one observer. That hot room determines fanout latency, queue depth, and reconnect recovery. An average hides it.

Build the stream as a repairable state machine

Use a monotonic sequence within each room. On a join, leave, or lease expiry, commit the new membership state and its sequence before publishing an event. A dashboard applies only the next expected sequence. If it sees a gap, it fetches a fresh snapshot and resumes from that snapshot's sequence. Polling survives, but as an error-recovery path rather than the steady-state read loop.

Recovery is the contract.

This is where edge cases live. Delivery may be duplicated after a retry, delayed across a reconnect, or observed after a newer snapshot. Make event application idempotent. Include a stable member identifier, room identifier, connection generation, event kind, and sequence; do not put a reusable room credential in the payload. If the same courier reconnects quickly, the new generation must win over a late leave from the old connection.

A minimal reducer can express that ordering rule without binding the client to a commercial service:

def apply_event(state: dict, event: dict) -> dict:
    expected = state["sequence"] + 1
    if event["sequence"] != expected:
        return {**state, "repair_required": True}

    members = dict(state["members"])
    member_id = event["member_id"]
    current = members.get(member_id)

    if event["kind"] == "joined":
        if current is None or event["generation"] >= current["generation"]:
            members[member_id] = {"generation": event["generation"]}
    elif event["kind"] == "left":
        if current and event["generation"] == current["generation"]:
            members.pop(member_id)

    return {
        "sequence": event["sequence"],
        "members": members,
        "repair_required": False,
    }
Enter fullscreen mode Exit fullscreen mode

Transport follows the interaction. Server-Sent Events fit a browser dashboard that only receives updates over the stream; the HTML standard defines their event-stream behavior and reconnection model. WebSocket provides a two-way protocol when the same connection must also carry client messages. A WebRTC data channel can carry peer data, but it does not replace the authoritative server-side membership record. Pick after listing directionality, proxy behavior, connection limits, and recovery requirements.

Backpressure must be explicit. Give each connection a bounded outbound queue. When a consumer falls behind, coalesce superseded presence changes or terminate the stream and force snapshot repair; an unbounded queue turns one paused tab into a memory-retention policy. Also add jitter to reconnect delays so a regional network recovery does not synchronize every dashboard.

Token scope, deployment, and the signals that catch lies

Issue the room credential only after authenticating the user and authorizing the shipment, depot, or incident assignment. Keep its audience, room scope, permitted action, issued-at time, expiry, and unique identifier narrow enough for the receiving service to validate. RFC 7519 defines registered JWT claims such as aud, exp, iat, and jti; it does not decide your authorization policy. Never log the token itself.

Deploy the publisher and snapshot reader against the same state transition boundary. If an event can become visible before its snapshot state is committed, a repairing client can fetch the old state and then discard the event that would have corrected it. An outbox or equivalent commit-linked publication pattern avoids that split without claiming impossible exactly-once delivery.

Monitor what the dispatcher experiences: active streams, connection churn, heartbeat expiry count, fanout queue age, sequence-gap repairs, snapshot latency, duplicate-event count, and time from committed membership change to client application. Break those metrics down by room-size bucket. A healthy median beside a bad largest-room percentile is still a presence incident.

Test disconnects, not just joins. Suspend a browser, drop the network without a graceful close, reconnect with an older cursor, duplicate a leave, deliver an old-generation leave after a new join, and expire a credential while the media session is active. The expected result should be stated before the test: which layer ends authorization, which layer updates presence, and how quickly observers converge.

The retention decision comes last. Keep the current snapshot and only enough ordered events to cover the supported reconnect window, then delete older presence history unless audit or compliance requirements explicitly demand it. This caps storage and limits exposure of operational metadata. The cost is forensic depth: after an event ages out, you can explain current membership and recent transitions, but you cannot replay an old room timeline from the presence store. Preserve longer-lived audit records separately, with a purpose, access policy, and deletion schedule.

Further reading

Top comments (0)