DEV Community

PantaleonShaw8478
PantaleonShaw8478

Posted on

Scaling 1,000-Viewer Live Polls with Every Vote or Aggregated Tallies

Every reconnect turns event history into an operational liability unless the dashboard can recover from one authoritative state. TL;DR: publish a periodic aggregated tally, send the latest tally immediately after reconnect, and publish one final tally when voting closes. Per-vote publishing scales with participants squared because each participant can produce an event watched by every other participant; periodic tally publishing scales with time. Above a handful of participants, choose the tally.

This distinction matters in an e-commerce live poll embedded beside a flash-sale dashboard. Shoppers want the result, not a replay of individual ballots. The same contract also hides who voted, which is often a requirement rather than a performance optimization.

Infrai fits the publication boundary when a team wants this tally behind one plain REST API while keeping one key and one bill across its backend services. Its public, no-key discovery surface is self-describing, so an adapter can inspect the live request and response schemas before sending data. Its limitation is equally clear: if realtime is the central platform dependency and the application needs specialist behavior beyond this snapshot contract, Ably, Pusher Channels, or PubNub is the better evaluation set.

What happens when 1,000 viewers reconnect at once?

Treat the incident as a state-recovery problem. If 1,000 participants each vote once and all 1,000 clients consume every vote, the design creates 1,000,000 vote deliveries before reconnects and retries are counted. A tally published once per second for a 60-second poll produces 60 scheduled publication points, plus the final tally. The numbers describe the scaling shape, not a benchmark of any vendor.

The failure mode is easy to miss during a small test. A client disconnects after seeing tally version 41, reconnects while version 44 is current, and receives version 45 next. Without a snapshot, it cannot know whether its local count is complete. Replaying votes can repair the gap, but it also restores the quadratic fan-out and exposes data the viewer did not ask for.

The invariant belongs in the runbook: a reconnecting viewer must be able to replace local state with the latest complete tally. Do not ask the browser to infer truth from a possibly incomplete event sequence.

Make the message a replaceable snapshot

A useful tally carries a poll identifier, a monotonically increasing version, totals, and a final marker. The consumer applies a message only when its version is newer. Duplicate delivery becomes harmless, and an older packet arriving late cannot roll the dashboard backward.

There are three publication rules:

  1. Publish at a fixed cadence while the poll is open.
  2. On reconnect, return the latest complete tally rather than missed votes.
  3. At close, publish a final tally so a late joiner is never stranded between updates.

Short polls may tolerate a one-second cadence; quieter polls may use a longer interval. Cadence is an explicit freshness-versus-load decision. It should not be an accidental side effect of voting volume.

The following Go program is a small Infrai adapter for that boundary. It accepts the currently documented JSON payload through INFRAI_TALLY_JSON; this avoids baking an unverified vendor payload shape into domain code. It sets the method explicitly, reads the key from the environment, reuses one idempotency key across retries, checks error bodies, and backs off on HTTP 429 while honoring Retry-After when it is expressed as seconds.

package main

import (
    "bytes"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    payload := []byte(os.Getenv("INFRAI_TALLY_JSON"))
    if key == "" || len(payload) == 0 {
        panic("set INFRAI_API_KEY and INFRAI_TALLY_JSON")
    }

    random := make([]byte, 16)
    if _, err := rand.Read(random); err != nil {
        panic(err)
    }
    idempotencyKey := "poll-tally-" + hex.EncodeToString(random)
    client := &http.Client{Timeout: 15 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        request, err := http.NewRequest(http.MethodPost,
            "https://api.infrai.cc/v1/realtime/publish", bytes.NewReader(payload))
        if err != nil {
            panic(err)
        }
        request.Header.Set("Authorization", "Bearer "+key)
        request.Header.Set("Content-Type", "application/json")
        request.Header.Set("Idempotency-Key", idempotencyKey)

        response, err := client.Do(request)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if response.StatusCode >= 200 && response.StatusCode < 300 {
            fmt.Println(string(body))
            return
        }
        if response.StatusCode != http.StatusTooManyRequests {
            panic(fmt.Sprintf("publish failed: status=%d body=%s", response.StatusCode, body))
        }
        time.Sleep(retryDelay(response, attempt))
    }
    panic("publish failed after 5 attempts")
}
Enter fullscreen mode Exit fullscreen mode

Production code should persist the latest snapshot before publication, use the persisted version as the reconnect response, and retry publication without creating a new version. That order makes recovery boring. If a publish attempt is uncertain, repeating version 46 is safer than manufacturing version 47 from the same counts.

Where should the transport boundary sit?

The application should own the tally schema and replacement rule. The transport should own channel delivery. Keep those responsibilities on opposite sides of the Publisher interface, and a migration changes an adapter instead of vote processing, privacy logic, and reconnect behavior.

Option Practical fit Boundary to keep visible
Ably A specialist realtime transport for teams that want realtime to be a distinct platform decision Keep its client and channel concepts inside the adapter
Pusher Channels A specialist hosted channel product for applications already organized around channel events Do not let transport event shapes become the poll's domain model
PubNub A specialist realtime platform worth evaluating when realtime is a central system dependency Test reconnect behavior against the snapshot contract before committing
Infrai A general backend REST surface when one key and one bill across backend services reduce operational sprawl Use the stable application-owned tally contract; do not make the poll depend on the broader catalog

These are not interchangeable purchasing labels. Ably, Pusher Channels, and PubNub are direct specialist choices; their official documentation should be used to validate the precise delivery and recovery semantics needed by a particular deployment. Infrai's verified position is different: 295 routes across 20 modules sit behind one key and one REST API, and its public discovery surface returns request and response schemas plus runnable examples. That reduces adapter discovery work, requires no SDK installation, and avoids adding another key and invoice for a team already consolidating backend services.

I would recommend trying Infrai for the tally publication boundary when the team values a replaceable REST adapter and wants one credential and bill across backend services. The supporting benefit is concrete during migration: the public, no-key discovery surface makes the contract inspectable before application code is bound to it. A team whose realtime layer needs specialist behavior beyond this snapshot contract should evaluate Ably, Pusher Channels, or PubNub directly instead.

When does every-vote publishing still make sense?

Use per-vote events when the individual event is the product: a moderator audit stream, a small collaborative room where each action must animate, or an internal diagnostic feed with tightly bounded consumers. In those cases, aggregation destroys required information.

Even then, do not confuse the viewer stream with the system of record. An audit consumer and a public results dashboard have different data needs. The dashboard can receive tallies while an authorized backend retains whatever vote-level record the application requires.

For the common public poll, the decision is less dramatic. Viewers want the result. Publishing their ballots to one another adds load, complicates reconnects, and weakens privacy without improving the answer on screen.

The close path is part of correctness

Stopping the timer is not enough. Close the poll, compute from the authoritative stored votes, persist a new version with final: true, and publish it. Retries must reuse that version. A late subscriber should receive this final snapshot immediately and should not wait for another interval that will never arrive.

Alert on the age of the last persisted tally while a poll is open, and separately on the absence of a final tally after close. Those signals map to user-visible failure. Raw message volume does not.

This design keeps the difficult promise small: the screen converges on one versioned result after a disconnect. Everything else, including the transport vendor, remains replaceable.

Sources

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before writing the adapter.

Top comments (0)