DEV Community

JamesAnderson121
JamesAnderson121

Posted on

Pushing Above 5 Viewers: Why It Beats Polling Costs

Pushing beats polling above a handful of viewers on a presence dashboard because polling repeats full reads even when nobody changes status. The transport is only half the decision, though: every event needs an ordered cursor, clients need a cheap snapshot, and the server needs bounded replay. Without those pieces, push lowers routine read amplification while making recovery ambiguous.

TL;DR: polling cost grows with viewers multiplied by polling frequency, even when nobody changes status. Push cost follows actual changes plus one delivery per connected viewer. For a marketplace workspace showing who is online, use a snapshot followed by cursor-addressed deltas, reconnect with bounded backfill, and force a fresh snapshot when the cursor is too old. This preserves correctness while moving the expensive path from empty reads to useful events.

Why does pushing beat polling above a handful of viewers?

Polling feels wonderfully plain in a notebook. Fetch the current online set, sleep, repeat. The trap appears when a live dashboard becomes shared. If there are V viewers and each polls every T seconds, the request rate is V / T, regardless of whether presence changes. Ten viewers on a two-second interval create five reads per second. One hundred viewers create fifty. Those are derived examples, not benchmark results, but the shape matters: idle work scales with the audience.

Push has a different curve. If presence changes E times per second and each change is fanned out to V viewers, delivery work is roughly proportional to E * V, alongside connection maintenance. There is no universal crossover point because payload size, authentication, database access, proxies, heartbeat policy, and transport implementation all matter. Still, above a handful of viewers, quiet or moderately active rooms make the wasted-read term hard to ignore.

That is the crossover.

Cost has four useful components: request handling, state-store reads, bytes transferred, and fan-out work. Polling pays the first three on every interval. Push pays connection overhead continuously and fan-out when state changes. Measure these components in the eval harness instead of turning the model into a universal price claim.

Fast is not correct.

A two-second poll interval can leave a change unseen until a later poll; shortening the interval improves freshness by buying more empty requests. Push decouples delivery from that fixed read cadence, although queues and slow consumers can still delay a message.

The trade-off is explicit: pushing is not suitable when long-lived connections cannot pass through the deployment path, when a view is opened by one operator for a few seconds, or when the source changes so rarely that a relaxed conditional poll is operationally cheaper. Choose polling for those cases. Choose pushed deltas for a shared workspace only after budgeting connection memory, heartbeat traffic, replay storage, and reconnect bursts. This boundary also keeps the conclusion honest: request-count arithmetic alone cannot prove that push wins in a particular production stack, and measured inputs are required to locate its crossover.

Build the reconnect contract before choosing a transport

The data flow is short. A client fetches a presence snapshot containing the current members and a cursor, opens a live stream from that cursor, and applies ordered online or offline events. After a disconnect, it asks for events after its last applied cursor. The server replays retained events when possible; if the requested cursor has expired, it returns a new snapshot and cursor. That final branch turns retention from a hidden assumption into a protocol rule.

Here is a runnable, transport-neutral model. The same state logic can sit behind a WebSocket session or another ordered delivery channel.

from collections import deque
from dataclasses import dataclass
from typing import Literal

Kind = Literal["online", "offline"]


@dataclass(frozen=True)
class PresenceEvent:
    cursor: int
    user_id: str
    kind: Kind


class PresenceLog:
    def __init__(self, retention: int = 100) -> None:
        self._cursor = 0
        self._online: set[str] = set()
        self._events: deque[PresenceEvent] = deque(maxlen=retention)

    def publish(self, user_id: str, kind: Kind) -> PresenceEvent:
        self._cursor += 1
        event = PresenceEvent(self._cursor, user_id, kind)
        if kind == "online":
            self._online.add(user_id)
        else:
            self._online.discard(user_id)
        self._events.append(event)
        return event

    def snapshot(self) -> tuple[set[str], int]:
        return set(self._online), self._cursor

    def resume_after(self, cursor: int) -> list[PresenceEvent] | None:
        if cursor == self._cursor:
            return []
        if not self._events or cursor < self._events[0].cursor - 1:
            return None
        return [event for event in self._events if event.cursor > cursor]


log = PresenceLog(retention=4)
log.publish("seller-17", "online")
members, cursor = log.snapshot()
log.publish("buyer-42", "online")
missed = log.resume_after(cursor)

assert members == {"seller-17"}
assert missed == [PresenceEvent(2, "buyer-42", "online")]
Enter fullscreen mode Exit fullscreen mode

None is not an error to retry forever. It means the replay window no longer covers the gap, so the client must replace local state with a new snapshot. An empty list means the cursor is current. Keeping those outcomes distinct prevents a reconnect loop from silently presenting stale members as online.

Recovery decides it.

The client should record the cursor only after applying its event. Delivery can repeat around reconnect boundaries, so applying an event with a cursor less than or equal to the last applied cursor should be a no-op. If multiple processes publish presence, cursor assignment needs one ordering authority per workspace partition; a process-local integer is only suitable for the compact example.

Model useful work, not just request count

A tiny cost model belongs beside the prototype because it makes assumptions reviewable. I use it as an eval input, not as a capacity promise. The output is units of work, intentionally detached from cloud pricing.

from dataclasses import dataclass


@dataclass(frozen=True)
class Workload:
    viewers: int
    poll_interval_seconds: float
    changes_per_second: float
    snapshot_units: float
    event_units: float
    connection_units_per_second: float


def polling_units_per_second(load: Workload) -> float:
    return (load.viewers / load.poll_interval_seconds) * load.snapshot_units


def push_units_per_second(load: Workload) -> float:
    connections = load.viewers * load.connection_units_per_second
    deliveries = load.changes_per_second * load.viewers * load.event_units
    return connections + deliveries


quiet_room = Workload(
    viewers=12,
    poll_interval_seconds=2.0,
    changes_per_second=0.05,
    snapshot_units=8.0,
    event_units=1.0,
    connection_units_per_second=0.1,
)

assert polling_units_per_second(quiet_room) == 48.0
assert push_units_per_second(quiet_room) == 1.8
Enter fullscreen mode Exit fullscreen mode

These numbers are hypothetical knobs. Replace them with measurements from the actual service: CPU time per authenticated request, cache or database reads per snapshot, compressed bytes, event serialization time, and connection memory. Sweep viewers, event rate, reconnect rate, and snapshot size. Prompt and model costs are absent because presence delivery should remain deterministic infrastructure; introducing a model call would add expense and nondeterminism without improving the state transition.

There is a regime where polling remains rational. A single admin screen opened briefly, a source that updates once an hour, or infrastructure that cannot sustain long-lived connections may favor a slower conditional poll. Push also loses its advantage if every event forces every client to refetch the full snapshot. The useful comparison is full-read amplification versus delta delivery, not HTTP versus a particular socket API.

Backpressure changes the curve again. A slow viewer cannot be allowed to grow an unbounded queue. Coalesce superseded presence updates, cap pending bytes or event count, and disconnect clients that fall beyond the replayable window. On reconnect, the snapshot path recovers them. Presence usually cares about the latest state more than every intermediate flicker, but audit events may have a different contract and should not share that lossy policy.

Test the gaps that happy-path demos hide

The first test should disconnect a client immediately after the server creates an event but before the client records its cursor. Reconnect may deliver the event again. The state reducer must remain correct. Next, hold a client offline until retention expires and verify that it replaces, rather than merges, its local member set with the new snapshot.

Then make ordering hostile. Deliver cursor 12 before cursor 11, duplicate cursor 12, and pause consumption while publishers continue. A client can buffer a small gap, but it needs a bound and a timeout; after that, resnapshotting is clearer than guessing. Also test authorization during reconnect. A valid old cursor must not let a viewer resume a workspace they can no longer access.

I would keep four eval dimensions visible in continuous integration: final online set, last applied cursor, number of resnapshots, and peak pending events. The workload matrix should vary viewer count independently from change rate because that separation exposes the weakness of polling. Include a nearly idle shared workspace, a burst of sign-ins, repeated network loss, and one deliberately slow consumer.

Use generated user IDs and a deterministic event seed. No model is needed to judge the result: replay the same trace through the reference reducer and compare final state. This notebook-to-production path is direct. The notebook establishes the cost surface and invariants; the production harness preserves the traces while substituting the real stream and storage adapters.

Operate presence as a state system

Deployment needs more than an open-connection graph. Track snapshot requests, live deliveries, duplicate events ignored, replayed events, replay misses, queue depth, disconnect reasons, and cursor lag. Split expected mobile reconnects from server-side termination. A rising replay-miss rate can mean retention is too short, reconnects are taking longer, or clients are persisting cursors incorrectly; one aggregate error counter cannot distinguish them.

Rollouts deserve versioned event schemas. Additive fields are easier to deploy than changing the meaning of an existing field, and consumers should tolerate fields they do not understand. If a breaking transition is unavoidable, snapshot responses and stream events need a shared schema version so a client never combines incompatible representations.

Capacity planning should include connection churn, not just steady connections. Authentication, snapshot generation, and replay lookup cluster around reconnect storms. Spread client retries with randomized delay, keep replay reads bounded by workspace and cursor, and load-test recovery after a simulated interruption. A system that handles ordinary fan-out but collapses while everyone reconnects has optimized the cheaper half.

The operational checklist is compact in prose. Confirm that cursors are monotonic within their stated scope, duplicate application is harmless, replay retention has a documented bound, and an expired cursor produces a replacement snapshot. Put limits on client queues and retry rates. Observe lag and replay misses by workspace size. Finally, rerun the workload sweep with production measurements after changing snapshot shape, heartbeat policy, compression, or retention, because each can move the crossover point.

Push wins this marketplace dashboard when shared viewing makes empty reads dominate and when reconnect behavior is designed as part of the data model. The durable architecture is snapshot, ordered delta, bounded replay, and explicit resynchronization. Transport comes later.

Sources

Top comments (0)