Low-Level Design: Poll and Voting System — Real-Time Results, Fraud Prevention, and Analytics

Core Entities

Poll: poll_id, creator_id, question, poll_type (SINGLE_CHOICE, MULTIPLE_CHOICE, RANKED, RATING), status (DRAFT, ACTIVE, CLOSED), is_anonymous, allow_change_vote, visibility (PUBLIC, PRIVATE, ORGANIZATION), start_time, end_time, settings (JSONB: max_choices, require_authentication, show_results_before_close), created_at. PollOption: option_id, poll_id, text, image_url, display_order. Vote: vote_id, poll_id, voter_id (NULL if anonymous), voter_fingerprint (browser fingerprint hash), selected_options (int array for multi-choice), rank_order (JSONB for ranked choice), created_at, ip_address, user_agent. VoteResult: poll_id, option_id, vote_count, percentage, last_updated. (Materialized aggregate, updated on each vote.) PollAnalytics: poll_id, total_votes, unique_voters, completion_rate, avg_response_time_seconds, geographic_distribution (JSONB), device_breakdown (JSONB), time_series (JSONB: votes per hour).

Vote Submission with Deduplication

class VotingService:
    def cast_vote(self, poll_id: int, voter_id: Optional[int],
                  options: list[int], request_meta: dict) -> VoteResult:
        poll = self.db.get_poll(poll_id)

        # Validate poll is active
        if poll.status != "ACTIVE":
            raise PollNotActive(poll_id)
        if poll.end_time and datetime.utcnow() > poll.end_time:
            raise PollExpired(poll_id)

        # Validate options
        valid_option_ids = {o.option_id for o in poll.options}
        if not all(o in valid_option_ids for o in options):
            raise InvalidOption()
        if poll.poll_type == "SINGLE_CHOICE" and len(options) != 1:
            raise InvalidSelection("Single choice poll requires exactly 1 option")

        # Deduplication key
        if voter_id:
            dedup_key = f"voted:{poll_id}:{voter_id}"
        else:
            # Anonymous: use IP + user agent fingerprint
            fingerprint = self._compute_fingerprint(request_meta)
            dedup_key = f"voted:{poll_id}:{fingerprint}"

        with db.transaction():
            # Check for existing vote (atomic with Redis for speed,
            # DB constraint as fallback)
            if self.redis.get(dedup_key):
                if poll.allow_change_vote:
                    return self._change_vote(poll_id, voter_id, options)
                raise AlreadyVoted()

            # Insert vote record
            vote = self.db.insert("votes", {
                "poll_id": poll_id, "voter_id": voter_id,
                "selected_options": options,
                "ip_address": request_meta["ip"],
                "user_agent": request_meta["user_agent"],
                "voter_fingerprint": fingerprint if not voter_id else None
            })

            # Update materialized vote counts (in same transaction)
            for option_id in options:
                self.db.execute(
                    "UPDATE vote_results SET vote_count = vote_count + 1 "
                    "WHERE poll_id = %s AND option_id = %s",
                    poll_id, option_id
                )
            self.db.execute(
                "UPDATE vote_results SET percentage = "
                "vote_count * 100.0 / (SELECT SUM(vote_count) FROM vote_results "
                "WHERE poll_id = %s) WHERE poll_id = %s",
                poll_id, poll_id
            )

        # Set dedup key in Redis (TTL = poll end time + 24h for safety)
        self.redis.setex(dedup_key, 86400 * 30, "1")

        # Publish real-time update
        self.pubsub.publish(f"poll:{poll_id}:update", vote.to_json())
        return self.db.get_vote_results(poll_id)

Real-Time Results with WebSocket

Live results update as votes come in. Architecture: on each vote, publish a PollUpdateEvent to Redis pub/sub channel (poll:{poll_id}:update). A WebSocket gateway subscribes to these channels and pushes updates to all connected clients watching that poll. Throttling: if a poll receives thousands of votes per second (viral poll), push at most one update per 500ms per connected client (debounce). Client receives: {option_id: count, …} partial update or full recalculation. Rate limiting updates: the pub/sub consumer tracks last-push timestamp per poll. If < 500ms since last push: buffer the update and schedule a delayed push. This prevents WebSocket message floods to thousands of clients on viral polls.

Fraud Detection and Vote Integrity

Fraud vectors: (1) Same user voting multiple times with different accounts (ballot stuffing). Detection: IP rate limiting (max N votes per IP per poll per hour), browser fingerprinting (canvas, WebGL, fonts), device ID tracking. (2) Bot voting: automated script votes. Detection: CAPTCHA on anonymous polls, rate limiting by IP, behavioral analysis (too-fast click pattern). (3) Vote buying: users selling votes. Mitigation: real-time anomaly detection on vote velocity per IP subnet. Implementation: track votes per IP in Redis sorted sets with sliding time windows. INCR vote_count:{poll_id}:{ip_prefix} with TTL. Threshold alerts: if any IP prefix votes > 50 times in 10 minutes: flag for manual review, require CAPTCHA for subsequent votes from that IP, and mark those votes as SUSPICIOUS in the database. Do not automatically delete suspicious votes — human review determines validity.

See also: Twitter/X Interview Prep

See also: Snap Interview Prep

See also: LinkedIn Interview Prep

Scroll to Top