Low Level Design: Poll and Voting System

Poll and voting systems appear in everything from social media reactions to high-stakes organizational decisions. The engineering challenges span idempotent vote recording, real-time aggregation at scale, anonymous voting that still prevents cheating, and result integrity that can survive audit. This post covers the full low level design of a robust poll and voting system.

Poll Data Model

The poll table is the root entity: poll_id (UUID), creator_id, question (text), options (JSONB array of {option_id, text}), vote_type (single-choice, multiple-choice, ranked-choice), visibility (public, private, unlisted), start_time, end_time (null for open-ended), allow_anonymous (boolean), show_results_before_close (boolean), created_at, status (draft, active, closed).

Options are stored inline as JSONB for simple polls. For polls with many options or options that require rich metadata (images, URLs), a separate poll_options table with option_id, poll_id, text, display_order, metadata is more appropriate. The trade-off: JSONB is simpler and faster for small option sets; a relational table is easier to query and index for analytics.

Poll categories and tags enable discovery: poll_tags table with poll_id and tag. Full-text search on question and option text via PostgreSQL tsvector or Elasticsearch. Featured polls are promoted via a featured_until timestamp column.

Vote Recording

The votes table: vote_id (UUID), poll_id, voter_id (null for anonymous), anonymous_token (null for authenticated), option_ids (array, supports multiple-choice and ranked-choice), rank_order (JSONB mapping option_id to rank position for ranked-choice), ip_address, user_agent, created_at.

Idempotency for authenticated users: a UNIQUE(poll_id, voter_id) constraint at the database level makes double-voting impossible regardless of application-layer bugs or race conditions. An INSERT ... ON CONFLICT DO NOTHING (or DO UPDATE for vote-change policies) handles concurrent duplicate submissions gracefully without errors exposed to the user.

Vote changes (allowing a user to change their vote) require ON CONFLICT DO UPDATE SET option_ids = EXCLUDED.option_ids, updated_at = now(). The old vote must be subtracted from result counters and the new vote added. This is cleanly handled if results are computed from the votes table on demand, but requires compensating events if results are maintained as running counters in a cache.

Real-Time Results

For low-traffic polls, computing results on demand via SELECT option_id, COUNT(*) FROM votes WHERE poll_id = ? GROUP BY option_id is sufficient and correct. For high-traffic polls with millions of votes, this query becomes expensive. The real-time aggregation architecture uses Redis and a message stream:

On each vote write: publish a vote_cast event to Kafka (poll_id, option_ids, voter_type). A stream processor (Kafka Streams or Flink) consumes these events and maintains running counts per option. Results are written to Redis: HINCRBY poll:{poll_id}:results {option_id} 1. The results endpoint reads from Redis—sub-millisecond latency regardless of total vote count.

Bootstrap: on first access to a poll's results (or on cache miss), compute results from the database and populate Redis. Set an ETag or version counter in Redis to detect staleness. The stream processor increments an event counter (poll:{poll_id}:version) on each vote; clients can use this as a cache-busting signal.

Result Integrity

For high-stakes votes (governance, elections, important organizational decisions), result integrity must be verifiable independently of trusting the application layer. Several techniques apply at different levels of rigor:

Append-only vote log: votes are never deleted or updated in the primary log. Cancellations are recorded as a separate vote_cancellation event referencing the original vote. The vote log in an append-only store (e.g., a write-once S3 bucket, an immutable Kafka topic with retention policy, or a ledger database) provides an independent source of truth.

Merkle tree commitment: after each vote, compute an incremental hash of the vote log. Periodically publish the root hash to an external, tamper-evident log (a transparency log or blockchain anchor). Any modification to historical votes would invalidate the hash chain and be detectable. This provides cryptographic proof that the vote log has not been altered after the fact.

Checksum reconciliation: a periodic job computes SHA256(poll_id || sorted(vote_ids) || sorted(option_counts)) and stores it in an audit table. After poll close, a second independent computation (ideally from a read replica snapshot taken at close time) verifies the checksum matches. Discrepancies trigger alerts and manual review.

Anonymous Voting

Anonymous voting requires preventing double-voting without linking the vote to an identity. The standard approach uses a keyed hash of session information:

anonymous_token = HMAC_SHA256(poll_id + ":" + session_id, server_secret)

The token is stored as the anonymous_token field in the vote record. A UNIQUE(poll_id, anonymous_token) constraint prevents the same session from voting twice. But given only the token and the stored value, you cannot recover session_id—the HMAC is one-way. You can verify a token (to check if this session already voted) but you cannot link a stored vote to an identity.

Limitations: anonymous tokens are per-session. A user who clears cookies, uses a different browser, or uses incognito mode gets a new token and could vote again. For polls where strict one-vote-per-person matters, you must either require authentication or accept that anonymous voting has inherent limitations. IP-based rate limiting adds a layer of friction but is easily bypassed with proxies.

Fraud Prevention

Vote fraud patterns include: coordinated bot voting, ballot stuffing via account creation, and vote manipulation by poll creator insiders. The defense layers:

Rate limiting: INCR votes:{ip_address}:minute in Redis with a 60-second TTL. If count exceeds threshold (e.g., 10 votes per minute per IP), reject with a 429 and require CAPTCHA. Separate limits for authenticated users (per user_id) vs. anonymous (per IP).

Device fingerprinting: collect browser fingerprint (user-agent, screen resolution, installed fonts, canvas fingerprint) and hash it. Track votes per fingerprint. Flag if the same fingerprint votes in more than N polls within a window—this detects automated browsers that rotate cookies.

Velocity anomaly detection: the expected vote arrival pattern for organic polls follows a roughly Poisson distribution after sharing events. A sudden burst of votes (100 votes in 5 seconds on a poll that had 10 votes per hour previously) is anomalous. Flag for review; optionally hold suspicious votes in a pending state while automated review runs. Machine learning models trained on historical vote patterns can distinguish organic spikes (from social sharing) from coordinated attacks.

For high-stakes polls, CAPTCHA at vote time is the strongest friction layer. The trade-off is user experience degradation. A tiered approach: no CAPTCHA for regular polls, optional CAPTCHA for creator-selected high-stakes polls, mandatory CAPTCHA when anomaly detection triggers.

Ranked Choice Voting

Ranked choice (instant runoff) voting is significantly more complex than plurality voting. The ballot captures an ordered preference list: voter ranks option A first, option C second, option B third. The rank_order JSONB field stores {option_id: rank} per vote.

The Instant Runoff Voting (IRV) algorithm:

  1. Count first-choice votes for each option.
  2. If any option has >50% of votes, it wins.
  3. Otherwise, eliminate the option with the fewest first-choice votes.
  4. For ballots that ranked the eliminated option first, move their vote to the voter’s next ranked option.
  5. Repeat from step 1 until one option has a majority.

This algorithm requires loading all ballots into memory or processing them iteratively. For large elections, it's computationally expensive and cannot be computed incrementally (unlike plurality vote counting). The correct approach: lock the ballot box when the poll closes, then run the IRV computation as a background job. The job reads all ballots, runs the algorithm, and writes the result (including the round-by-round elimination history for transparency) to a ranked_results table. Display shows "Results computing…" until the job completes, then switches to final results.

Results Display

Live results updates during an active poll use either polling or WebSocket push. Polling: the client requests GET /polls/{poll_id}/results every 5 seconds. The response includes a version number; the client only re-renders if the version changed. WebSocket push: the server maintains a subscription list per poll; on each vote processed by the stream aggregator, it broadcasts updated counts to all subscribers. WebSocket is more efficient at scale but adds infrastructure complexity.

Results visualization: percentage bar per option (votes for option / total votes), absolute vote count, total participation count, and participation rate (votes cast / eligible voters for authenticated polls). For ranked-choice polls, show the round-by-round elimination view as an expandable detail.

Historical snapshots: record result state at each 10% completion milestone (10% of expected votes, 20%, etc.) and at regular time intervals. These snapshots support analysis of how results evolved during the voting period—valuable for detecting manipulation (results that shifted dramatically in a short window) and for post-vote analysis. Snapshots are written by the stream processor when crossing thresholds and stored in a poll_result_snapshots table with poll_id, snapshot_at, vote_count, results_json.

{ “@context”: “https://schema.org”, “@type”: “FAQPage”, “mainEntity”: [ { “@type”: “Question”, “name”: “How do you prevent double voting for anonymous users who do not have accounts?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Combine multiple weak signals: set a first-party cookie with a random voter token on first vote, fingerprint the browser (user-agent, screen resolution, canvas hash, timezone) to derive a stable anonymous ID, and record the IP address. Store a Bloom filter or hash set of seen (poll_id, voter_token) pairs for fast deduplication. For higher assurance, require a lightweight proof-of-work challenge or a CAPTCHA on anonymous votes. Accept that no single mechanism is foolproof; layer them to raise the cost of stuffing.” } }, { “@type”: “Question”, “name”: “How do you aggregate real-time vote counts at scale using Redis?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Use Redis INCR on keys like poll:{poll_id}:option:{option_id} for O(1) atomic increments. Pub/Sub or Redis Streams fan out updates to connected WebSocket servers so clients see live counts. To reduce write amplification under extreme load, batch votes in a local counter per app instance and flush to Redis every 100ms. Periodically snapshot Redis counts to a durable store (Postgres/MySQL) and use Redis as a read-through cache so counts survive a Redis restart.” } }, { “@type”: “Question”, “name”: “How do you implement a ranked choice voting tabulation algorithm?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Store each ballot as an ordered list of candidate IDs. Run Instant Runoff Voting (IRV): count first-choice votes per candidate; if no candidate has a majority, eliminate the candidate with the fewest first-choice votes and redistribute their ballots to the next ranked candidate still in the running. Repeat until a majority winner emerges or only one candidate remains. Implement elimination as a set-difference filter applied during ballot traversal. For large datasets, run tabulation as a batch MapReduce job to avoid single-node memory limits.” } }, { “@type”: “Question”, “name”: “How do you use Merkle trees to verify vote integrity?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Assign each submitted vote a unique ID and hash its contents (voter token, option, timestamp, nonce). Build a Merkle tree over all vote hashes in insertion order; publish the root hash publicly after the poll closes. Any auditor can request a Merkle proof for a specific vote (a path of sibling hashes from leaf to root) and verify it against the published root in O(log n) steps without accessing other votes. Append-only storage and periodic root snapshots allow detection of any post-close tampering.” } }, { “@type”: “Question”, “name”: “What fraud prevention techniques apply to high-stakes polls?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Layer multiple controls: enforce authenticated voting with verified identity (email/phone OTP or OAuth); apply rate limiting per IP, device, and account to throttle coordinated attacks; use anomaly detection to flag sudden vote-velocity spikes or geographically clustered submissions; require a CAPTCHA for suspicious sessions; log all votes with immutable audit trail (append-only DB table or blockchain anchor); and run post-poll statistical analysis (e.g., Benford’s law on timestamps, chi-square on option distribution) to surface manipulation patterns.” } } ] }
Scroll to Top