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=(",", ":"))})

This is the same trick a payment gateway uses to make sure double-tapping “Pay Now” never charges a card twice: the client, not the server, mints a unique request id up front, and the server’s very first job is to check whether it has ever seen that id before doing anything else. What breaks without it: a viewer on a shaky mobile connection who taps once but whose client retries three times over a slow network ends up counted three times, and at a million-voter scale that pattern alone can shift a close result.

Watch Out

A common mistake is sizing the idempotency cache’s TTL and memory budget for average load instead of peak burst. If the cache evicts a key under memory pressure before a legitimate retry arrives, the retry looks like a brand-new vote. The gateway’s cache is a fast-path optimization, not the only line of defense, a unique constraint on the durable ledger catches anything that slips past it, but an undersized cache still means more requests fall through to the slower, more expensive defense.

The Voter Identity and Anti-Stuffing Engine

This component’s job is to decide, without an account system to consult, whether a vote came from a distinct, plausible human, and to attach a continuous trust score to every vote rather than a binary allow or block.

A smart engineer’s first instinct is to block anything that looks automated outright, reject the request with an error the instant a signal looks suspicious. That instinct is wrong for two reasons: a hard block trains an attacker to probe for the exact threshold and adjust until they slip under it, and a false positive on a genuinely fast, enthusiastic human voter turns into a visible, undeserved rejection. Instead every vote gets a continuous trust score between zero and one, computed from several independent signals, and only votes below a floor are excluded from the tally, silently, without telling the sender anything changed.

# Vote identity resolution + trust scoring: a repeat voter_token updates
# their existing vote (last-vote-wins) instead of creating a second counted
# vote, and every vote gets a continuous trust score used to filter abuse
TRUST_FLOOR = 0.15  # below this, a vote is shadow-counted, not dropped

def trust_score(signals: dict) -> float:
    score = 1.0

    # Requests arriving faster than a human can plausibly tap twice.
    interval = signals.get("seconds_since_last_vote_this_device")
    if interval is not None and interval < 0.4:
        score *= 0.2

    # A device/IP pair issuing far more votes than one human plausibly casts.
    votes_from_ip_last_minute = signals.get("votes_from_ip_last_minute", 1)
    if votes_from_ip_last_minute > 20:
        score *= max(0.05, 20 / votes_from_ip_last_minute)

    # A WebSocket connection opened only seconds before voting looks scripted;
    # a viewer who has been watching for minutes looks organic.
    conn_age = signals.get("ws_connection_age_seconds", 999)
    if conn_age < 2.0:
        score *= 0.5

    # Known-bad ASN / datacenter IP ranges (proxies, cloud scripts) rather
    # than residential or mobile carrier ranges.
    if signals.get("asn_is_datacenter", False):
        score *= 0.3

    return max(0.0, min(1.0, score))

def resolve_vote(existing_vote: dict | None, new_option_id: str,
                  signals: dict, now: float) -> dict:
    score = trust_score(signals)
    action = "insert" if existing_vote is None else "change"
    if existing_vote and existing_vote["option_id"] == new_option_id:
        action = "noop"  # re-submission of the same choice, not a real change
    return {
        "action": action,
        "previous_option_id": existing_vote["option_id"] if existing_vote else None,
        "new_option_id": new_option_id,
        "trust_score": score,
        "counted": score >= TRUST_FLOOR,
        "resolved_at": now,
    }

Think of this like a polling place clerk checking a name off a printed roll before handing over a ballot, except there is no printed roll, so the clerk instead infers “have I seen this exact combination of device signals before” from a fingerprint rather than a name. What breaks without last-vote-wins semantics: a viewer who changes their mind partway through the window, taps a different option, either gets rejected outright or ends up double counted across two options, both of which are worse outcomes than simply moving their one vote.

Real World

This mirrors how ad-fraud detection pipelines at companies like Cloudflare score a request’s bot-likelihood as a continuous value rather than a binary block. A hard binary block trains attackers to probe for the exact threshold; a continuous score lets downstream logic, here, whether a vote counts toward the tally, act on nuance instead of an all-or-nothing gate.

The Sharded Vote Counters

This component’s job is to absorb the actual increment volume, up to and beyond 100,000 writes per second during a burst, without any two writers ever contending for the same counter.

A single counter row per option, even one backed by an in-memory store instead of a locked database row, still serializes every writer that touches it. At 100,000 writes/sec against one key, even a store capable of millions of ops/sec per key starts to show queueing latency once network round-trips and connection multiplexing enter the picture, and that queueing is exactly what a p99 submission latency budget of 150ms cannot absorb. The fix is to split each option’s count into a fixed number of independent shard counters and hash each voter deterministically to exactly one shard, so writes for the same option land on different physical keys and never wait on each other.

# Sharded increment: a vote is hashed to one of N shards per option so
# concurrent writers never contend on the same counter key
import hashlib

SHARDS_PER_OPTION = 16

def shard_for_voter(voter_token: str, shards: int = SHARDS_PER_OPTION) -> int:
    digest = hashlib.blake2b(voter_token.encode(), digest_size=8).digest()
    return int.from_bytes(digest, "big") % shards

def apply_counted_vote(redis_client, poll_id: str, resolved: dict, voter_token: str) -> None:
    shard = shard_for_voter(voter_token)
    if resolved["action"] in ("insert", "change") and resolved["counted"]:
        if resolved["previous_option_id"]:
            prev_key = f"cnt:{poll_id}:{resolved['previous_option_id']}:{shard}"
            redis_client.decr(prev_key)
        new_key = f"cnt:{poll_id}:{resolved['new_option_id']}:{shard}"
        redis_client.incr(new_key)
Internals of the sharded vote counter tier showing a voter token hashed to one of sixteen shard counters per option, the tally aggregator summing all shards on a fixed interval, and a checkpoint store used for crash recovery

This is the same technique a county elections office uses when it lets every precinct count its own ballot box independently rather than funneling every physical ballot through one central counting table. What breaks without sharding: every vote for a popular option contends for the exact same key, and the shard count effectively drops to one, reproducing the same lock-contention bottleneck a single global counter has, just implemented in a faster store.

Key Insight

Each shard is only ever incremented or decremented by the worker holding that specific vote, using a single atomic INCR or DECR, so the write path needs zero cross-shard coordination. The only place shard count matters is the read side, where the aggregator has to sum across all of them, and that read happens far less often than writes happen, on a fixed 500ms cadence rather than once per vote.

The Tally Aggregator

This component’s job is to sum every shard counter for every option on a fixed cadence and publish a single, coherent snapshot, rather than letting every individual increment ripple out as its own message.

Publishing a broadcast message for every single vote sounds maximally live, but it means write volume and broadcast volume are the same number, and at 100,000 votes/sec that is 100,000 messages/sec that somehow have to reach 5,000,000 viewer connections, an amplification that no fan-out tier can sustain. The aggregator decouples the two by reading all shard counters on its own schedule and producing one snapshot per interval regardless of how many votes landed during that interval, turning an unbounded, bursty write rate into a fixed, predictable publish rate.

// Tally aggregator: sums N shard counters per option on a fixed cadence
// and publishes a snapshot rather than one message per vote
package tally

import (
	"context"
	"strconv"
	"time"

	"github.com/redis/go-redis/v9"
)

const shardsPerOption = 16
const publishInterval = 500 * time.Millisecond

func aggregateOnce(ctx context.Context, rdb *redis.Client, pollID string, optionIDs []string) (map[string]int64, error) {
	totals := make(map[string]int64, len(optionIDs))
	for _, opt := range optionIDs {
		keys := make([]string, shardsPerOption)
		for s := 0; s < shardsPerOption; s++ {
			keys[s] = "cnt:" + pollID + ":" + opt + ":" + strconv.Itoa(s)
		}
		vals, err := rdb.MGet(ctx, keys...).Result()
		if err != nil {
			return nil, err
		}
		var sum int64
		for _, v := range vals {
			if v == nil {
				continue
			}
			n, _ := strconv.ParseInt(v.(string), 10, 64)
			sum += n
		}
		totals[opt] = sum
	}
	return totals, nil
}

func RunAggregatorLoop(ctx context.Context, rdb *redis.Client, pollID string, optionIDs []string, publish func(map[string]int64)) {
	ticker := time.NewTicker(publishInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			totals, err := aggregateOnce(ctx, rdb, pollID, optionIDs)
			if err == nil {
				publish(totals)
			}
		}
	}
}

Think of this like the telethon operator’s walk down the wall of clickers: the operator doesn’t shout a new total after every single call, they walk the wall on their own rhythm and shout whatever the sum happens to be at that moment. What breaks without a fixed publish cadence: the aggregation cost couples directly to vote volume instead of shard count, so the exact moment the system is under the most write pressure is also the moment it tries to publish the most often, compounding load right when the system can least afford it.

Watch Out

The most tempting shortcut is to skip the aggregator entirely and have the broadcast layer read shard counters directly on every viewer’s own refresh schedule. That turns a bounded, predictable read load (one aggregator reading N shard keys on a fixed interval) into an unbounded one (millions of viewer connections each independently hammering the same shard keys), which recreates the exact hot-key problem sharding was meant to solve, just one layer further downstream.

The WebSocket Broadcast Fan-out

This component’s job is to take the aggregator’s periodic snapshot and push it to every connected viewer as a small diff, spread across enough independent pods that no single process has to hold millions of sockets.

The naive approach opens one WebSocket server process and lets every viewer connect to it. That process runs out of file descriptors and memory long before it reaches a million connections, and even if it didn’t, a single process crashing would silently disconnect the entire audience at once. Each fan-out pod instead holds a bounded slice of the total viewer connections, subscribes to the same tally snapshots as every other pod, and computes its own diff against the last snapshot it sent, so a viewer only ever receives the numbers that actually changed.

// WebSocket fan-out: each pod holds a shard of viewer connections and
// re-publishes every aggregator snapshot as a small diff, not a full resend
package fanout

import (
	"encoding/json"
	"sync"
)

type Snapshot struct {
	PollID string           `json:"poll_id"`
	Totals map[string]int64 `json:"totals"`
	AsOf   int64            `json:"as_of_ms"`
}

type Pod struct {
	mu    sync.RWMutex
	conns map[string]chan []byte // viewer_id -> outbound channel
	last  Snapshot
}

func (p *Pod) OnSnapshot(next Snapshot) {
	p.mu.Lock()
	diff := diffTotals(p.last.Totals, next.Totals)
	p.last = next
	p.mu.Unlock()

	if len(diff) == 0 {
		return
	}
	payload, _ := json.Marshal(struct {
		PollID string           `json:"poll_id"`
		Diff   map[string]int64 `json:"diff"`
		AsOf   int64            `json:"as_of_ms"`
	}{next.PollID, diff, next.AsOf})

	p.mu.RLock()
	defer p.mu.RUnlock()
	for _, ch := range p.conns {
		select {
		case ch <- payload:
		default:
			// Slow consumer: drop this tick rather than block the fan-out loop;
			// the next snapshot supersedes it anyway.
		}
	}
}

func diffTotals(prev, next map[string]int64) map[string]int64 {
	diff := make(map[string]int64)
	for k, v := range next {
		if prev[k] != v {
			diff[k] = v
		}
	}
	return diff
}

The analogy is a radio station’s regional relay towers, each covering a slice of the listening area, all retransmitting the exact same broadcast rather than the studio trying to run a dedicated wire to every single radio in the city. What breaks without horizontal fan-out pods and diffing: connection count and message size both grow unbounded together, and a viewer base that spikes from a hundred thousand to five million mid-broadcast, which is exactly when a poll is at its most interesting, takes the entire broadcast layer down with it.

Real World

Slido and Kahoot, the live-audience-polling products used at conferences and classrooms, both fan tally updates out over WebSocket or SSE channels driven by a periodic snapshot rather than a push per vote, for exactly this reason: audience size and vote volume both spike unpredictably, and a fixed publish cadence keeps broadcast cost bounded regardless of how bursty the underlying voting gets.

Data Model

The data model spans four entities: polls, poll options, an append-only vote ledger that is the system’s real source of truth, and periodic shard checkpoints used for crash recovery.

-- Polls: the bounded voting window a broadcast opens and closes
CREATE TABLE polls (
    poll_id        UUID        PRIMARY KEY,
    broadcast_id   TEXT        NOT NULL,
    question       TEXT        NOT NULL,
    opens_at       TIMESTAMPTZ NOT NULL,
    closes_at      TIMESTAMPTZ NOT NULL,
    status         TEXT        NOT NULL DEFAULT 'scheduled'
        CHECK (status IN ('scheduled', 'open', 'closed', 'reconciled')),
    CHECK (closes_at > opens_at)
);

-- Poll options: the fixed set of choices a vote can target
CREATE TABLE poll_options (
    option_id      UUID        PRIMARY KEY,
    poll_id        UUID        NOT NULL REFERENCES polls(poll_id),
    label          TEXT        NOT NULL,
    display_order  INT         NOT NULL
);

-- Vote ledger: append-only source of truth. A voter_token can appear more
-- than once (vote changes); the reconciliation query keeps only the latest.
CREATE TABLE vote_ledger (
    vote_id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    poll_id           UUID        NOT NULL REFERENCES polls(poll_id),
    option_id         UUID        NOT NULL REFERENCES poll_options(option_id),
    voter_token       TEXT        NOT NULL,
    idempotency_key   TEXT        NOT NULL,
    trust_score       REAL        NOT NULL CHECK (trust_score >= 0 AND trust_score <= 1),
    submitted_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (poll_id, idempotency_key)
);

CREATE INDEX ON vote_ledger (poll_id, voter_token, submitted_at DESC);

-- Shard checkpoints: durable, periodic snapshots of the in-memory shard
-- counters, used to recover approximate live totals after a crash
CREATE TABLE shard_checkpoints (
    poll_id          UUID        NOT NULL REFERENCES polls(poll_id),
    option_id        UUID        NOT NULL REFERENCES poll_options(option_id),
    shard_id         INT         NOT NULL,
    vote_count       BIGINT      NOT NULL,
    checkpointed_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (poll_id, option_id, shard_id)
);

vote_ledger is the only table treated as authoritative; every other structure in the system, the shard counters, the aggregator snapshots, the broadcast diffs, is a derived, disposable view over it. The UNIQUE (poll_id, idempotency_key) constraint is the durable second line of defense behind the gateway’s Redis-based idempotency check: if the cache evicted a key under memory pressure and let a duplicate slip through, the ledger insert itself fails on conflict and the duplicate is caught before it can be double-counted. The index on (poll_id, voter_token, submitted_at DESC) exists specifically to make the reconciliation query, which needs each voter’s latest choice, fast even against tens of millions of ledger rows.

Data flow diagram showing a vote's lifecycle from submission through idempotency check, dedup and trust scoring, shard increment, ledger insert, and the poll close transition into exact reconciliation and final tally

A vote is born the moment a viewer taps an option: it clears the gateway’s idempotency check, gets resolved and trust-scored by the identity engine, and, if it clears the trust floor, is hashed into a shard counter and written as a new row in vote_ledger. That row never changes after it is written, a vote change is a brand-new row with a later submitted_at, not an update to the old one. Throughout the open window, the tally shown to viewers comes entirely from the shard counters and the aggregator’s periodic sums. The moment closes_at is reached, the system stops trusting the shard counters as the final word and instead runs the reconciliation query against the ledger, which becomes the number that ships as the poll’s official result.

Key Insight

Storing vote changes as new append-only rows rather than updating an existing row is what makes the ledger safely replayable. An update-in-place table can’t tell you what the tally looked like at any earlier point, and it invites lost-update races between concurrent vote changes. An append-only ledger sidesteps both problems, and the storage cost of keeping every historical row for a thirty-second poll is negligible next to the correctness it buys.

Key Algorithms and Protocols

Idempotent Vote Submission

Covered in the Vote Ingestion Gateway section above, this is a SET NX check against a shared cache, O(1) per request, backed by a unique constraint on the durable ledger as a second, slower line of defense.

Key Insight

The property that makes this safe under a cache eviction is defense in depth: the fast path (Redis SET NX) and the slow path (a database unique constraint) both enforce the same invariant independently. A duplicate that slips past the fast path because of memory pressure is still caught by the slow path before it can affect the ledger, just at a slightly higher cost.

Trust Scoring

Covered in the Voter Identity and Anti-Stuffing Engine section above, the trust function combines several independent signals multiplicatively, each in O(1), into a continuous score in [0, 1] rather than a binary decision.

Key Insight

The property that makes a continuous score more resilient than a hard rule is that an attacker calibrating against a binary block only has to find the one threshold that avoids it. A continuous score combining multiple independent signals multiplicatively means evading detection on every single signal simultaneously is required to keep the product near 1.0, which is a meaningfully harder bar to clear than beating one rule.

Counter Sharding and Merge

Covered in the Sharded Vote Counters and Tally Aggregator sections above, writes hash a voter token to one of N shards in O(1), and the aggregator sums across all shards for all options in O(shards * options) per publish tick, independent of vote volume.

Key Insight

The property that makes this scale is that the expensive-per-vote operation, the atomic increment, never coordinates across shards, while the cross-shard coordination that does exist, the aggregator’s summing read, runs at a fixed rate decoupled from vote volume. Doubling the vote rate doubles write load but never touches read (aggregation) cost.

Exact Reconciliation via Ledger Replay

At poll close, the shard counters stop being authoritative and the system recomputes the tally directly from the durable ledger, keeping only each voter’s most recent, trust-cleared vote.

-- Exact reconciliation at poll close: replays the append-only vote ledger,
-- keeping only each voter's final choice, to produce the authoritative count
SELECT option_id, COUNT(*) AS exact_votes
FROM (
    SELECT DISTINCT ON (voter_token) voter_token, option_id
    FROM vote_ledger
    WHERE poll_id = $1
      AND trust_score >= 0.15
    ORDER BY voter_token, submitted_at DESC
) final_votes
GROUP BY option_id
ORDER BY exact_votes DESC;

This runs in O(n log n) for n ledger rows in the poll, dominated by the sort backing DISTINCT ON, and the (poll_id, voter_token, submitted_at DESC) index defined earlier lets Postgres satisfy that ordering directly from the index rather than sorting the whole table in memory.

Key Insight

The property that makes reconciliation trustworthy is that it uses the exact same trust floor and the exact same last-vote-wins rule the live counting path used, applied once, precisely, instead of accumulated approximately across many small increments and decrements. The live number and the final number can disagree by a small margin during the window; they are computed from the same rules, so they never disagree about what those rules are.

Scaling and Performance

The Sharded Vote Counters and the WebSocket Broadcast Fan-out are the two components under the most direct load, and they scale along different axes: counters scale by adding shards per option, fan-out scales by adding pods and spreading connections across them with consistent hashing on viewer id.

Scaling diagram showing an auto-scaled ingestion tier, a counter shard tier where a viral poll is automatically re-sharded to more shards under load, and a consistent-hashed WebSocket fan-out tier serving five million concurrent viewers

A poll that unexpectedly goes viral partway through its window, a moment gets clipped and shared, a celebrity retweets it, can see vote volume jump by an order of magnitude with no warning. The shard count per option is not fixed at poll creation time; once a poll’s measured write rate crosses a threshold, it is automatically re-sharded to a higher shard count, the same fix a hot key gets anywhere else in a distributed system, split it finer rather than throwing more compute at the same partition. The fan-out tier scales independently and continuously, since connection count (viewers) and write volume (voters) are only loosely correlated, a poll can have five million viewers and a hundred thousand voters at the same time.

Capacity Estimation:

Given:
  - 1,000,000 votes submitted within a 30-second live voting window
  - Average vote payload: ~180 bytes (poll_id, option_id, voter_token,
    idempotency_key, signals)
  - 16 shards per option, 4 options per poll = 64 shard counters
  - Aggregator publish interval: 500ms
  - 5,000,000 concurrent viewer WebSocket connections watching the tally

Sustained vote throughput:
  1,000,000 votes / 30 sec = ~33,333 votes/sec average
  Peak burst (first/last few seconds of the window): ~100,000-120,000 votes/sec

Ingestion bandwidth:
  33,333 votes/sec * 180 bytes = ~6 MB/sec average inbound
  120,000 votes/sec * 180 bytes = ~21.6 MB/sec at peak burst

Shard write rate (per shard, evenly hashed):
  120,000 votes/sec / 64 shard counters = ~1,875 INCR ops/sec/shard at peak,
  comfortably inside a single Redis shard's single-digit-microsecond INCR cost

Aggregation reads:
  64 shard keys read via MGET every 500ms = 128 MGET ops/sec total,
  independent of vote volume, since aggregation cost scales with shard
  count, not vote count

Broadcast fan-out bandwidth:
  5,000,000 connections * ~40 bytes/diff message * 2 messages/sec
  (500ms cadence) = ~400 MB/sec aggregate egress across the fan-out tier,
  split across roughly 50 fan-out pods at 100,000 connections/pod
  = ~8 MB/sec egress per pod

The dominant bottleneck under normal load is not the aggregate numbers above, all of which are modest per shard and per pod, it is the transition moments: the first second of a window when every viewer’s client tries to submit at once, and the handful of seconds around a viral spike when a poll’s shard count is still catching up to its new write rate. Both are addressed the same way, detect the rate change fast and re-partition before latency degrades, rather than waiting for a latency SLA breach to trigger a response.

Real World

This mirrors how large live-event platforms handle bursty audience interaction generally: Twitch’s chat and prediction systems and similar live-voting products at large streaming platforms rely on sharded counters feeding a periodic, decoupled broadcast layer rather than a single synchronous counter, precisely because the write burst at the start of a live moment is the load pattern that breaks naive designs first.

Failure Modes and Recovery

FailureDetectionImpactRecovery
Idempotency cache (Redis) evicts a key under memory pressure before a legitimate retry arrivesLedger insert fails on the (poll_id, idempotency_key) unique constraint instead of the fast-path cache catching itA retry briefly falls through to the slower defense layer instead of the fast oneThe ledger’s unique constraint rejects the duplicate insert; the request is treated as already_accepted and no shard counter is touched twice
A vote counter shard’s process or node crashesHealth check / heartbeat failure on that shard’s processThe displayed live tally briefly undercounts that shard’s votesThe most recent checkpoint from shard_checkpoints restores an approximate value, and the gap fully self-heals at poll close, since the final tally is always recomputed exactly from vote_ledger, never from shard counters
A ballot-stuffing script floods votes from a single device or IP at superhuman velocityTrust scoring detects abnormal submission velocity and datacenter/proxy signals in real timeWithout mitigation, one option could receive a disproportionate share of votesScore falls below TRUST_FLOOR; votes are shadow-filtered from both the live tally and the final reconciliation, but still logged in the ledger for audit, without alerting the sender
A WebSocket fan-out pod crashes mid-broadcastConnection drop detected by the load balancer / client heartbeatViewers connected to that pod stop receiving tally updatesClient auto-reconnects to a healthy pod via the load balancer; the new pod immediately pulls the latest snapshot rather than waiting for the next 500ms tick, so the viewer catches up instantly
Clock skew between ingestion nodes near the poll’s closes_at boundaryVote timestamps recorded with node-local clocks diverge from the poll’s authoritative close timeVotes arriving right at the boundary could be inconsistently included or excluded depending on which node processed themPoll close is enforced by a single authoritative timestamp source rather than each node deciding locally, and a short grace buffer accepts votes slightly after closes_at into a late bucket resolved during reconciliation
The aggregator’s publish pipeline backs up during a viral spikePublish queue depth or fan-out lag alert crosses a thresholdThe displayed tally lags beyond the 2-second consistency windowThe aggregator degrades from a 500ms to a longer interval under backpressure rather than falling further behind, and each fan-out pod independently caps its own egress rate
Watch Out

The most common operational mistake is picking a shard count once, at poll design time, and never revisiting it. A poll that stays at its planned scale runs fine with a fixed shard count; a poll that unexpectedly goes viral mid-broadcast hits the exact hot-key contention sharding was supposed to prevent, right at the moment the audience, and the stakes of a slow or wrong-looking tally, are largest.

Comparison of Approaches

ApproachLatencyComplexityFailure modeBest fit
Single global atomic counter (UPDATE ... SET count = count + 1 under a row lock)Fast at low volume, degrades sharply under contentionLowRow-lock contention serializes every writer into one queue, becoming the bottleneck at high QPSSmall internal polls with a few hundred voters
Sharded in-memory counters with periodic aggregation (this design)Low, bounded by the consistency window (about 2 seconds)MediumA crashed shard silently undercounts until checkpoint recovery or poll-close reconciliationLarge live-broadcast polls needing near-real-time display at high write volume
CRDT G-Counter replicated across regionsLow locally, eventually consistent globallyHighMerge overhead grows with node count, and a batch reconciliation step is still needed for an exact final numberGlobally distributed audiences voting from multiple regions simultaneously
Fully synchronous durable counter (every vote a committed database transaction with strongly consistent reads)Slow, write latency dominated by fsync and replicationLow-mediumThroughput is hard-capped by the durability path; a burst overwhelms the write path directlyLow-volume polls where exact real-time consistency matters more than raw scale, such as shareholder or governance votes
Append-only log plus stream processing recompute (Kafka feeding a continuous stream aggregation job)Medium, bound by the stream processor’s checkpoint intervalHighReprocessing lag under backpressure, plus the operational complexity of running a full streaming platformPolls needing rich real-time analytics beyond a simple tally, such as demographic breakdowns or trending segments

We would pick sharded in-memory counters with periodic aggregation, backed by ledger-based exact reconciliation at close, for any broadcast-scale live poll, because it is the only approach here that keeps both halves of the requirement honest: a fast, bounded-staleness live number and an exact final one. A single global counter fails on throughput alone. A CRDT approach adds coordination complexity that single-region broadcasts do not need. A fully synchronous counter trades away exactly the write throughput a burst requires. A full streaming platform is overkill unless the product actually needs richer real-time analytics than a running tally.

Key Takeaways

  • Idempotent submission and vote deduplication solve two different problems. Idempotency protects against a client retrying the same request; deduplication protects against the same voter submitting genuinely new requests more than once.
  • Counter sharding turns a hot key into many cold ones by hashing each voter to exactly one shard, so concurrent writers for the same popular option never contend with each other.
  • The live tally and the final tally are computed by two different mechanisms on purpose. The live number is an approximate, fast, periodically-aggregated snapshot; the final number is an exact replay of the durable, append-only vote ledger.
  • Anti-stuffing works better as a continuous trust score than a binary block, since a hard threshold trains attackers to probe for the exact line while a continuous score punishes them for failing on any one of several independent signals.
  • A bounded result consistency window (here, about 2 seconds) is a deliberate design choice, not a limitation to apologize for. Chasing per-vote exactness in the live number would have made the write path slower without making the poll more trustworthy to a viewer who can’t verify it live anyway.
  • The aggregator decouples write volume from broadcast volume. Publishing on a fixed cadence rather than per vote keeps fan-out cost predictable regardless of how bursty the underlying vote rate gets.
  • Hot polls need the same fix as any other hot shard: split finer, don’t just add more compute to the same partition.
  • An append-only ledger, not an update-in-place counter, is what makes exact reconciliation possible after the fact.

The counter-intuitive lesson is that the number flashing on screen during the broadcast is deliberately allowed to be wrong within a bounded window. Exactness is pushed entirely to poll close, on purpose, because a viewer watching a live number has no way to verify it in the moment anyway, and spending engineering effort making the live number provably exact would have slowed down the one path, ingestion, that actually determines whether the system survives the burst.

Frequently Asked Questions

Q: Why not use one global counter with a lock instead of sharded counters?

A: A single counter serializes every writer behind one key, and at a peak of over 100,000 votes/sec that serialization becomes the bottleneck long before any individual operation’s raw cost does. Sharding turns one hot key into many independent cold ones, so writers for the same popular option never wait on each other, at the cost of needing a small aggregation step to read the total.

Q: Why not require login or accounts to prevent ballot stuffing?

A: Requiring an account before voting adds friction that most live-broadcast audiences won’t tolerate for a thirty-second window, and it doesn’t actually prevent stuffing, an attacker can script account creation just as easily as they can script anonymous votes. Device and behavioral signals, scored continuously, catch scripted abuse without adding a barrier that costs real participation from honest viewers.

Q: How does the system prevent someone from voting twice using two different devices?

A: It doesn’t, fully, and that’s a deliberate scope boundary rather than an oversight. Without accounts, there is no durable identity to tie two physical devices together with certainty; the system optimizes for making single-device, scripted stuffing expensive and detectable, not for perfectly attributing every vote to one human across every device they own. This tradeoff is explicit in the non-functional requirement to resist a 50x single-source attack, not a cross-device one.

Q: What happens if the trust score wrongly flags a legitimate, fast voter as suspicious?

A: Because the score is continuous rather than a hard block, a borderline-fast legitimate vote is more likely to land just above the trust floor than to be excluded outright, and unlike a hard rule, nothing in the response tells the voter anything happened differently. The floor is tuned conservatively specifically to bias toward false negatives (missing some real abuse) over false positives (silently dropping real votes), since the reputational cost of visibly under-counting real votes is higher than the cost of a small amount of undetected low-effort abuse.

Q: Why publish tally updates every 500ms instead of pushing every vote immediately?

A: Pushing every vote as its own broadcast message ties broadcast volume directly to vote volume, and at 100,000 votes/sec fanned out to 5,000,000 viewers, that amplification is not sustainable by any fan-out tier. A fixed publish cadence decouples the two, turning an unbounded, bursty rate into a fixed, predictable one, at the cost of the bounded staleness window the non-functional requirements already budget for.

Q: How is the final result guaranteed to be exact if the live number shown during the poll was only approximate?

A: The live number and the final number are computed by entirely different code paths reading from entirely different sources. The live number sums sharded, in-memory counters on a timer; the final number replays the durable, append-only vote ledger once, applying the same trust floor and last-vote-wins rule precisely rather than approximately. The two numbers can disagree slightly while the poll is open; only the ledger replay ships as the official result.

Interview Questions

Q: Walk through what happens, end to end, when a viewer taps a poll option and the tally on their own screen updates a moment later.

Expected depth: Cover the idempotency key check at the gateway, identity resolution and trust scoring, the hash-to-shard increment, the durable ledger insert, the aggregator’s next scheduled sum across all shards, and the fan-out pod computing a diff and pushing it to that viewer’s own connection, and why none of these steps happen synchronously in response to the tap itself past the initial acknowledgment.

Q: How would you detect and respond to a poll unexpectedly going viral mid-broadcast, where vote volume jumps by 10x with no warning?

Expected depth: Discuss monitoring per-poll write rate in real time, the threshold that triggers increasing shard count for that poll’s counters, why re-sharding mid-poll requires a migration strategy for in-flight counts (not just increasing N going forward), and how the WebSocket fan-out tier scales independently since viewer count and voter count aren’t the same number.

Q: Design the anti-stuffing trust scoring function. What signals would you include, and how would you avoid it becoming a binary block an attacker can simply probe against?

Expected depth: Cover combining multiple independent, weakly-correlated signals (submission velocity, IP/ASN reputation, connection age, device fingerprint reuse) multiplicatively into a continuous score, why a continuous floor resists probing better than a hard rule, and the tradeoff between false positives (dropping real votes) and false negatives (missing scripted abuse).

Q: Why does this design compute the live tally and the final tally using two different mechanisms instead of one? What would go wrong if you tried to make the live number always exactly match the eventual final number?

Expected depth: Discuss the lambda-architecture-style split between a fast, approximate speed layer (sharded counters, periodic aggregation) and an exact batch layer (ledger replay at close), and why forcing per-vote exactness into the live path would require exactly the kind of cross-writer coordination that sharding exists to avoid.

Q: How would you extend this design to support many concurrent independent polls running across different live broadcasts at the same time, rather than one poll at a time?

Expected depth: Cover partitioning shard counters, ledger writes, and aggregator instances by poll_id, why each poll’s aggregator can run as an independent per-poll leader rather than one global aggregator, and how the WebSocket fan-out tier’s consistent hashing needs to account for viewers subscribing to different polls without one poll’s viral spike starving another poll’s broadcast bandwidth.

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