Build a Live Poll and Real-Time Vote Tally System


scalability distributed-systems performance

System Design Deep Dive

Live Poll and Real-Time Vote Tally

Counting a million votes from a live audience in real time, without accounts, without double-counting anyone.

⏱ 15 min read 📐 Advanced 🏗️ Scalability

Picture an old telethon studio: a wall of phone lines, one clicker counter bolted next to each phone, and an operator whose whole job is to walk the wall every few seconds, add up all the clickers, and shout the running total to the host standing in front of the camera. No single clicker knows the total. No single phone line has to wait for any other phone line to be answered before its own call counts. The operator’s job isn’t to answer every call personally, it’s to sum numbers that are already being counted in parallel, on a schedule fast enough that the number on air never looks stale.

That is the shape of a live poll during a broadcast. A host asks a question, puts a QR code or a button on screen, and somewhere between a few hundred thousand and a million viewers tap an option within a thirty-second window, no login screen, no account creation, nothing standing between the tap and the vote. The number displayed back to the audience has to keep climbing in near real time, because the climbing number is part of the entertainment, and it has to be trustworthy enough that nobody can look at the result and reasonably claim one option won because a script hammered the vote button.

The naive version of this system is a single row in a database: UPDATE poll_options SET vote_count = vote_count + 1 WHERE option_id = ?. It works perfectly in a demo. It falls over the instant real load hits it, because every one of a million concurrent voters is now queued behind a single row lock, and the queue itself becomes the bottleneck long before the database’s raw throughput does. The opposite naive version, batch every vote and only compute the tally once the poll closes, avoids the lock contention entirely but throws away the one thing that makes a live poll compelling: a number the audience watches move in front of their eyes. Both mistakes come from treating “count the votes” and “show the count” as the same problem solved the same way, when at this scale they need genuinely different mechanisms, one optimized for absorbing a burst of writes, the other for cheaply reading a number that is allowed to be slightly stale.

We need to solve for three things simultaneously: an ingestion and counting path that can absorb a million votes in a thirty-second window without any single counter becoming a point of contention, a way to tell one honest vote from a script replaying the same request thousands of times a second, without ever asking anyone to sign in, and a broadcast path that keeps a live number moving on millions of screens without turning every single vote into a message sent to every single viewer.

Requirements and Constraints

Functional Requirements

  • Accept a vote submission tied to a specific poll option during a bounded voting window, without requiring the voter to create an account or log in
  • Treat every vote submission as idempotent: retries of the same client request, caused by a flaky network or a double-tap, must never increment the tally more than once
  • Deduplicate votes from the same voter across multiple submissions using non-account signals (device fingerprint, session token, IP plus client entropy), so at most one vote per voter counts toward the tally at any moment, with vote changes allowed before the poll closes (last vote wins)
  • Maintain a continuously updating tally per poll option, visible to every connected viewer, not just the ones who voted
  • Broadcast tally updates to every connected viewer over a push channel (WebSocket) rather than requiring viewers to poll an HTTP endpoint for updates
  • Score every vote against anti-stuffing signals (submission velocity, device fingerprint reuse, IP reputation, connection age, behavioral timing) and down-weight or shadow-filter suspicious votes without immediately telling the sender they were caught
  • Close the poll at a fixed time and publish a final, exact, reconciled tally that resolves any approximate counting drift accumulated during the live window

Non-Functional Requirements

  • Scale: 1,000,000 concurrent votes during a 30-second live voting window, sustained around 33,000 votes/sec and bursting above 100,000 votes/sec at the open and close of the window; up to 5,000,000 concurrent viewer WebSocket connections watching the tally update
  • Latency: vote submission acknowledgment at p99 under 150ms; a tally update must reach a connected viewer within a bounded consistency window of 2 seconds of the underlying vote landing
  • Durability: every accepted vote is durably recorded before its client receives a success acknowledgment, so no vote is lost even if a shard counter process crashes moments later
  • Correctness guarantee: the final, closed-poll tally must be exact, not approximate, and must equal the true count of deduplicated, trust-cleared votes
  • Availability: vote ingestion targets 99.95% uptime during the live broadcast window, since a voting endpoint that is down during a live TV moment cannot be meaningfully recovered after the fact
  • Abuse resistance: sustain at least a 50x single-source vote-stuffing attempt (one device or script submitting votes far above any human-plausible rate) without materially moving the published tally

Constraints and Assumptions

  • Viewer authentication, account creation, and login are explicitly out of scope; voter identity for deduplication purposes is inferred from device and session signals, not a durable account system
  • The video and broadcast delivery pipeline, the live stream itself, is a separate system; this design owns only the poll widget’s vote submission, tally computation, and result broadcast
  • We assume a CDN and edge network already exists for static assets and can terminate the bulk of inbound WebSocket connections close to viewers
  • Cross-poll analytics, historical poll browsing, and long-term archival beyond the single broadcast are out of scope
  • We assume one poll is open at a time per broadcast, though the design generalizes cleanly to many concurrent independent polls, one per live channel

High-Level Architecture

The system has five major components: the Vote Ingestion Gateway, the Voter Identity and Anti-Stuffing Engine, the Sharded Vote Counters, the Tally Aggregator, and the WebSocket Broadcast Fan-out.

Architecture overview showing the vote ingestion gateway, voter identity and anti-stuffing engine, sharded vote counters, vote ledger, tally aggregator, and WebSocket broadcast fan-out to viewers

The Vote Ingestion Gateway is the front door: it validates a vote’s shape, enforces the idempotency key so retries never double count, and applies coarse per-device rate limiting before anything expensive happens. The Voter Identity and Anti-Stuffing Engine resolves who is voting without ever asking for a login, deduplicates repeat submissions from the same voter, and scores every vote for how likely it is to be scripted abuse rather than a real tap. The Sharded Vote Counters are the hot write path, a bank of independent atomic counters per option that absorb the actual increment volume without any writer contending with any other writer. The Tally Aggregator is the operator walking the wall of clickers, it sums the shard counters on a fixed cadence and produces a single approximate-but-fresh snapshot of the current standings. The WebSocket Broadcast Fan-out takes that snapshot and pushes it out to every connected viewer as a small diff, not a full resend, spread across many fan-out pods so no single process has to hold millions of connections.

A vote’s life looks like this: a viewer taps an option, the client attaches a locally generated idempotency key and whatever device signals are available, and the request lands at the gateway. The gateway checks the idempotency key, the identity engine resolves the voter’s token and scores the vote’s trustworthiness, and if the vote clears the trust floor it gets hashed to one of several shard counters for that option and incremented atomically. Independently, on a fixed interval, the aggregator sums every shard across every option and publishes a snapshot; the fan-out tier diffs that snapshot against what it last sent and pushes only the changed numbers to every viewer it holds a connection for. When the poll’s close time arrives, the system stops treating the shard counters as authoritative and instead replays the durable vote ledger to compute an exact, reconciled final count.

Key Insight

The single most important architectural decision is that the number the audience watches live and the number that ships as the official result are computed by two different mechanisms on purpose. The live number is a fast, approximate snapshot summed from sharded counters on a fixed cadence. The final number is an exact replay of the durable vote ledger, deduplicated and trust-filtered, computed once at poll close. Trying to make the live number exact at every instant is what makes a naive design fall over; accepting a bounded staleness window while the poll is open is what makes the write path scale.

Component Deep Dives

The Vote Ingestion Gateway

This component’s job is to turn a raw HTTP or WebSocket vote submission into a validated, idempotent request before any counting logic ever runs.

The obvious mistake is checking idempotency after doing the expensive work, resolving identity, scoring trust, incrementing a shard, and only then noticing the request was a duplicate. Under a retry storm, that ordering means every duplicate still pays the full cost of processing, which is exactly the load pattern most likely to appear during the first and last seconds of a voting window when clients are retrying against a suddenly-busy endpoint. The gateway instead makes the idempotency check the very first gate a request passes through, using a fast SET NX against a shared cache so a duplicate is recognized and returned the exact same acknowledgment it got the first time, without touching anything downstream.

# Idempotent vote intake: SET NX with the client's idempotency key guarantees
# a retried request is recognized and acknowledged without re-processing
import time
import redis

r = redis.Redis(host="idempotency-cache", decode_responses=True)
IDEMPOTENCY_TTL_SECONDS = 60  # covers realistic client retry windows

def accept_vote(poll_id: str, option_id: str, idempotency_key: str,
                 voter_token: str, raw_signals: dict) -> dict:
    dedupe_key = f"idem:{poll_id}:{idempotency_key}"
    claimed = r.set(dedupe_key, "1", nx=True, ex=IDEMPOTENCY_TTL_SECONDS)
    if not claimed:
        # Same request already accepted once; return the same ack, do not requeue.
        return {"status": "already_accepted", "idempotency_key": idempotency_key}

    envelope = {
        "poll_id": poll_id,
        "option_id": option_id,
        "voter_token": voter_token,
        "idempotency_key": idempotency_key,
        "signals": raw_signals,
        "received_at": time.time(),
    }
    enqueue_for_dedup(envelope)
    return {"status": "accepted", "idempotency_key": idempotency_key}

def enqueue_for_dedup(envelope: dict) -> None:
    import json
    r.xadd(f"votes:{envelope['poll_id']}:stream",
           {"payload": json.dumps(envelope, separators=(",", ":"))})

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article