DEV Community

BeckettHayes6821
BeckettHayes6821

Posted on

How to Publish an Aggregated Tally in Node.js Live Polls — 2 Channels

Publish aggregated tallies to viewers, retain accepted votes in a durable log, and keep collaborative cursors on a separate ephemeral channel. The deciding constraint is reconnect and backfill: a clinician returning to a live care-plan poll needs the current count and proof that their vote was accepted, while cursor positions that are already stale have no useful history.

TL;DR: treat a vote as a command, the tally as a replaceable projection, and a cursor as disposable presence. Sending every accepted vote to every viewer makes fan-out grow with both vote rate and audience size; publishing a bounded tally at a fixed cadence makes viewer traffic grow mainly with audience size. Preserve the vote log for audit and rebuild, but do not confuse storage granularity with broadcast granularity.

Should a live poll publish every vote or an aggregated tally?

Imagine a healthtech editor where a care team annotates a plan, sees collaborators' cursors, and runs a live poll on the next intervention. Three streams appear adjacent in the UI, but their recovery semantics differ.

A vote must survive a process restart and must be deduplicated. A tally is derived state: after a disconnect, the client needs a fresh snapshot plus later revisions, not every intermediate count it missed. A cursor is transient; replaying a pointer position from 20 seconds ago would make the interface less truthful. This split also narrows the privacy surface: an audience channel can carry approved totals without inheriting voter identifiers or clinical context from the command path, although aggregation alone is not a substitute for an authorization policy or a minimum-group-size rule.

That distinction matters more than the transport. WebSocket, Server-Sent Events, and a WebRTC data channel can all move updates, but none decides which history remains meaningful. WebRTC data channels, for example, can be configured for ordered or unordered delivery and limited retransmission; those controls do not turn ephemeral cursor data into an auditable vote ledger. The limitation of the aggregate path is equally concrete: it hides event timing and cannot support a product whose viewers are meant to react to individual, public votes as they arrive. In that case, publish the approved vote event and retain the snapshot path for reconnects.

Use monotonically increasing projection revisions. On reconnect, the viewer sends its last seen revision, but the server may answer with the latest complete snapshot regardless; a snapshot is cheap, deterministic, and avoids retaining an unbounded per-viewer replay buffer. The individual voter gets an acknowledgement keyed by an idempotency token. Other viewers get only tally revision N.

No replay for cursors.

Different data, different promise.

Separate the write path from the broadcast path

The safe path has four operations: validate a vote, append it durably under a unique idempotency key, update the tally transactionally or through an ordered consumer, and publish a new projection on a bounded cadence. If the projection worker stops, accepted votes remain in the log and tally delivery becomes stale; it must never invent a count to appear available.

This Go example is deliberately transport-neutral. The interface can sit behind a Node.js edge service without making the edge responsible for correctness. Record is the durable boundary, while Snapshots is allowed to coalesce multiple changes.

package poll

import (
    "context"
    "errors"
    "time"
)

type Vote struct {
    PollID         string
    OptionID       string
    VoterID        string
    IdempotencyKey string
}

type Tally struct {
    PollID   string
    Revision uint64
    Counts   map[string]uint64
    AsOf     time.Time
}

type Store interface {
    // Record must atomically reject a reused key and persist an accepted vote.
    Record(ctx context.Context, vote Vote) (accepted bool, err error)
    CurrentTally(ctx context.Context, pollID string) (Tally, error)
}

type Broadcaster interface {
    PublishTally(ctx context.Context, tally Tally) error
}

func AcceptVote(ctx context.Context, store Store, vote Vote) error {
    if vote.PollID == "" || vote.OptionID == "" || vote.IdempotencyKey == "" {
        return errors.New("missing vote identity")
    }
    _, err := store.Record(ctx, vote)
    return err
}
Enter fullscreen mode Exit fullscreen mode

Do not use the broadcast as the acknowledgement. A viewer connection can disappear after persistence but before delivery, and retrying the vote must return the prior result rather than add a second vote. That is why the idempotency key belongs at the durable write boundary.

The projection publisher should cap frequency instead of sleeping once after each vote, which can drift under continuous load. A ticker gives a defined upper bound on update frequency; a dirty flag prevents empty broadcasts.

package poll

import (
    "context"
    "time"
)

func PublishTallies(
    ctx context.Context,
    store Store,
    out Broadcaster,
    pollID string,
    dirty <-chan struct{},
    interval time.Duration,
) error {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    pending := false
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-dirty:
            pending = true
        case <-ticker.C:
            if !pending {
                continue
            }
            tally, err := store.CurrentTally(ctx, pollID)
            if err != nil {
                return err
            }
            if err := out.PublishTally(ctx, tally); err != nil {
                return err
            }
            pending = false
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The interval is an SLO decision, not a magic constant. If the product promises that 99% of displayed tallies are no more than two seconds behind accepted votes, budget that two seconds across persistence, projection lag, batching, delivery, and rendering. A one-second publisher can already consume half the budget in the worst phase alignment, before queues or networks contribute anything. This is the trade-off: a longer interval buys more coalescing and steadier fan-out at the cost of visible freshness, while a shorter interval approaches per-event load during a quiet poll and can create synchronized bursts across many active polls. Measure the full age distribution at the browser, because a healthy publisher metric can conceal queueing downstream.

Capacity-plan both choices

Start with symbols, then substitute measurements from a load test. Let V be accepted votes per second, C connected viewers, S_v the encoded per-vote message size, S_t the encoded tally size, and F tally publications per second. Ignoring protocol overhead, every-vote egress is approximately V * C * S_v; aggregate egress is F * C * S_t. Durable ingestion remains V either way.

That separation prevents a common planning error: aggregation reduces fan-out work, but it does not reduce the integrity work required to accept votes. At 400 accepted votes per second and 10,000 viewers, individual broadcasting requests four million message deliveries per second before retries. A tally published twice per second requests 20,000 deliveries per second. These are illustrative inputs to the formula, not benchmark results; payload encoding, connection distribution, and transport overhead still need measurement.

Decision Publish every vote Publish aggregated tally
Viewer freshness Event-level Bounded by projection and publish interval
Reconnect payload Missed-event replay or snapshot Latest snapshot
Fan-out driver Vote rate multiplied by viewers Publish frequency multiplied by viewers
Audit value Only if events also remain durable Durable log remains separate
Information exposure May reveal event timing or voter-linked fields Can expose only approved counts

For a live poll, aggregated publication is the default because viewers normally consume totals rather than a forensic event stream. It is not suitable when event order itself is the product, when a moderator must inspect each public action in real time, or when the interface promises an animation for each accepted vote; choose the individual event stream for those viewers, subject to privacy controls. Even then, build the tally snapshot path: reconnecting every client through a long replay tail couples recovery time to outage duration and vote volume.

The cursor channel receives a different capacity budget. Sample or coalesce positions, attach an expiry, and tolerate loss. If the client sends 30 pointer movements per second, that does not create a requirement to render or retain all 30. Current position wins.

Choose ownership before choosing infrastructure

The buy-versus-build question belongs after the delivery contract is clear. A managed realtime service can remove connection fleet operations; a self-hosted gateway can offer tighter control over placement and protocol behavior. Neither removes the durable vote store, projection correctness, privacy review, or application-level idempotency.

Concern Managed connection layer Self-hosted connection layer
On-call scope Connection fleet is partly transferred; application semantics remain Team owns fleet, upgrades, saturation, and application semantics
Control Constrained by exposed retention, ordering, and backpressure controls Greater protocol and deployment control, with more operational work
Lock-in pressure SDKs, channel semantics, and replay behavior can become coupling points Internal APIs and chosen components can still create coupling
Capacity evidence Validate quotas and overload behavior with tests Validate node limits, balancing, and overload behavior with tests

I would require a small portability boundary: publish a typed tally with a revision, subscribe by poll ID, and fetch the authoritative snapshot through the application API. Keep vendor-specific connection identifiers outside the vote domain. The reason is prosaic: migration is possible when the contract describes state, while a contract shaped around one broker's delivery receipts tends to spread into handlers, metrics, and tests.

Three product comparisons are unnecessary here. The operational decision can be made from the contract and measured limits, and naming services would turn an architecture runbook into a shortlist without adding evidence.

Verify failure behavior, then define rollback

Test reconnects as a state transition, not merely as socket churn. During a load test, disconnect a cohort, continue accepting votes, reconnect it with old revisions, and assert that each viewer converges to the latest snapshot without counts moving backward. Duplicate a percentage of vote requests with the same idempotency keys and verify the accepted count does not change. Delay the projection worker. Drop cursor packets. Restart a broadcaster.

The primary service-level indicators are accepted-vote error rate, durable-write latency, projection lag in seconds, oldest displayed tally age, reconnect convergence time, duplicate rejection rate, and outbound queue depth. Connection count alone is a weak health signal: a server can hold many sockets while delivering stale state.

Alert on user-visible budget consumption. A page for projection lag should link to a runbook that can distinguish ingestion trouble from a stalled projector or saturated fan-out workers. Log poll ID, tally revision, and idempotency outcome, but keep voter identity and clinical content out of routine delivery logs unless policy explicitly requires and protects them.

Rollback must preserve accepted votes. If a new batching release increases tally age or queue depth, stop that publisher, restore the previous projector from the durable log, rebuild the current tally, and publish a higher revision snapshot. Do not roll the database back to an earlier count, and do not replay old cursors. Clients must ignore tally revisions lower than the highest revision they have applied.

One final invariant is worth automating: for each poll, the sum of option counts in revision N must equal the number of valid, accepted votes included through that projection checkpoint. The exact checkpoint representation depends on the store, but the invariant does not.

References

Top comments (0)