hardSystem Design

Real-Time Polling & Voting System

Design a real-time polling system with live vote counting, animated result transitions, anti-cheating mechanisms, and WebSocket-driven updates supporting millions of concurrent voters.

40 min read

Problem Statement

A real-time polling and voting system is a high-concurrency, low-latency frontend application where the core challenge is processing tens of thousands of simultaneous vote submissions while streaming live result updates to every connected viewer without visible stutter or data inconsistency.

This design covers the frontend architecture for systems in the class of Slido (live event Q&A and polls), Mentimeter (interactive presentations), Kahoot (gamified quizzes with countdown timers), Twitter/X Polls (embedded inline voting), and Strawpoll (lightweight instant polls).

Scope includes the poll creation interface, voter submission flow, real-time result streaming, animated result visualization, presenter mode (projector-optimized), voter mode (mobile-optimized), poll embed modes (iframe, web component, SDK), countdown timer synchronization, poll analytics, anti-cheating mechanisms, and offline/reconnect resilience.

Backend vote tallying internals, ML-based fraud detection pipelines, and email notification delivery are out of scope except where they define the client contract.

This problem is not equivalent to rendering a form. A polling system is a real-time aggregation projection where the primary constraint is read/write asymmetry under extreme concurrency. During a live event with 100K connected viewers, a single poll generates 50K+ votes in under 30 seconds. The frontend must optimistically reflect the user's own vote, stream aggregated results from the server, animate transitions between states, and prevent duplicate submissions β€” all while maintaining 60fps on mid-range mobile devices.

Slido proves that event-driven context requires sub-second result propagation. Mentimeter proves that presenter displays must be decoupled from voter interactions. Kahoot proves that countdown synchronization creates perceived fairness. Twitter Polls prove that embedded polls must function without navigation. Strawpoll proves that anonymous voting requires anti-abuse without authentication.

πŸ’‘

The architectural center of this system is not the form or the chart. It is the pipeline that transforms 100K discrete vote events into a smooth, real-time aggregation visualization without dropping frames or losing votes.


Requirements Exploration

Functional Requirements

  1. Presenters can create polls with configurable types: single-choice, multi-choice (max N selections), ranked-choice, word cloud, and open-ended text.
  2. Voters can submit their choice and immediately see their vote reflected in the results (optimistic update within 50ms).
  3. All connected clients receive aggregated result updates within 500ms of any vote submission.
  4. Polls support configurable countdown timers with server-synchronized start/end times.
  5. Presenters see a dedicated presenter view optimized for projection (large fonts, high contrast, animated transitions).
  6. Voters see a mobile-first voter view with clear tap targets and immediate feedback.
  7. Polls can be embedded in external sites via iframe, web component (<poll-widget>), or JavaScript SDK.
  8. Results render as animated bar charts, pie charts, word clouds, or ranked lists depending on poll type.
  9. Presenters can lock/unlock polls, reveal results progressively, and reset votes.
  10. The system enforces one-vote-per-entity (user, device, or session depending on configuration).
  11. Voters can change their vote while the poll is open (if allowed by poll settings).
  12. Poll analytics show turnout rate, vote timing distribution, geographic distribution, and drop-off metrics.

Non-Functional Requirements

CategoryRequirementTarget
PerformanceTime to Interactive (voter view)< 1.5s on 4G mid-range mobile
PerformanceVote submission to local reflection< 50ms (optimistic)
PerformanceVote submission to global propagation< 500ms p95
PerformanceResult animation frame rate60fps sustained
Bundle SizeVoter view initial JS< 80KB gzipped
Bundle SizePresenter view initial JS< 150KB gzipped
ConcurrencySimultaneous voters per poll100K+
ConcurrencyWebSocket connections per server50K (with connection pooling)
ReliabilityVote delivery guaranteeAt-least-once with deduplication
AvailabilityUptime during live events99.95%
LatencyWebSocket message propagation< 200ms p99 server to client
AccessibilityWCAG complianceAA minimum
SecurityDuplicate vote prevention< 0.01% slip-through rate

Capacity Estimation & Constraints

Traffic profile for a large live event (100K attendees):

MetricValueDerivation
Peak concurrent viewers100,000Single keynote poll
Votes in first 30s50,00050% participation rate
Peak vote RPS~1,70050K / 30s
WebSocket messages out per vote100,000Fan-out to all viewers
Total outbound messages/s~170M messages/30s1,700 votes Γ— 100K viewers
Result aggregation frequencyEvery 200msBatched to reduce fan-out
Aggregated broadcasts/s5One per 200ms window
Actual outbound messages/s500K5 broadcasts Γ— 100K viewers

Payload sizes:

PayloadSizeNotes
Vote submission~120B{ pollId, optionId, timestamp, nonce }
Aggregated result update~500B{ results: [{id, count, pct}], totalVotes, ts }
Full poll state (initial load)~2KBOptions, metadata, current results
Presenter view full state~5KBIncludes analytics, voter breakdown

Client memory budget:

LayerBudgetContents
Poll state (active)50KBCurrent poll + results + animation state
WebSocket buffer100KBMessage queue for reconnection
Chart rendering200KBCanvas/SVG render tree
Total voter view< 500KBHard ceiling for mobile

Bandwidth per viewer:

  • Initial load: ~80KB (JS) + ~2KB (poll state) = ~82KB
  • Ongoing: 5 messages/s Γ— 500B = 2.5KB/s = ~150KB/min
  • 10-minute session: ~1.5MB total data transfer

These numbers drive two architectural decisions:

  1. Result broadcasting must be batched (200ms windows) not per-vote to avoid 170M messages/s.
  2. The voter bundle must be aggressively split from the presenter bundle β€” voters need only submission + result display.

Architecture / High-Level Design

Rendering Strategy

Voter view: Client-Side Rendering (CSR) served from CDN. Justification: Voter pages are identical for all users (no personalization until vote submission). The shell loads from edge cache in < 200ms. Poll data hydrates via WebSocket on connect. SSR adds no value because the content is entirely dynamic.

Presenter view: CSR with preloaded poll data via API. Justification: Presenters authenticate before viewing. The view is fully interactive (controls, analytics). No SEO requirement. Initial poll state is fetched via REST, then streams via WebSocket.

Embed widget: CSR in shadow DOM (web component) or sandboxed iframe. Justification: Embeds must not conflict with host page styles or scripts. Shadow DOM isolation prevents CSS leakage. Iframe provides security isolation for untrusted hosts.

Single-Page Application within each mode (voter/presenter). Modes are separate entry points:

  • /poll/:id β€” Voter view (public, no auth required)
  • /present/:id β€” Presenter view (authenticated)
  • /create β€” Poll creation wizard (authenticated)
  • /analytics/:id β€” Post-event analytics (authenticated)
  • /embed/:id β€” Embed renderer (frameable, minimal shell)

URL state encodes: pollId, view (results/waiting/closed), and optional theme override for embeds. No client-side routing between voter and presenter β€” they are separate bundles deployed to the same origin.

System Architecture Diagram

Loading diagram...

Component Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  App Shell                        β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚         WebSocket Provider                 β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  β”‚
β”‚  β”‚  β”‚       Poll State Provider            β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚VoterView  β”‚  β”‚PresenterView  β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚           β”‚  β”‚               β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”‚VoteFormβ”‚ β”‚  β”‚ β”‚ResultChartβ”‚ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”‚Results β”‚ β”‚  β”‚ β”‚PollControlβ”‚ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”‚Display β”‚ β”‚  β”‚ β”‚   Panel   β”‚ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”‚  β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”‚Timer  β”‚ β”‚  β”‚ β”‚ Analytics β”‚ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β”‚Displayβ”‚ β”‚  β”‚ β”‚  Panel    β”‚ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚   β”‚  β”‚  β”‚
β”‚  β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Server components: None β€” both views are fully client-rendered. The CDN serves a static HTML shell with <script> tags.

Client components: Everything below App Shell. The WebSocket Provider and Poll State Provider are context-based providers. Views are code-split by entry point.

State Management Strategy

State CategoryStorageSync Mechanism
Poll metadata (title, options, type)In-memory store (Zustand)REST fetch on connect, WS updates for changes
Aggregated resultsIn-memory storeWS push every 200ms
User's own voteIn-memory + localStorageOptimistic local β†’ confirmed by server ACK
Timer stateDerived from server clock offsetNTP-lite sync on connect, local countdown
Presenter controlsIn-memory storeREST mutations + WS confirmations
Connection stateIn-memoryWebSocket lifecycle events
Vote nonce (anti-replay)sessionStorageGenerated per vote, cleared on ACK
πŸ’‘

The voter's own vote state uses a three-phase model: pending β†’ optimistic β†’ confirmed. The optimistic phase renders immediately on submission. If the server rejects (duplicate, closed, invalid), the state rolls back to pending with an error message. This ensures the voter always sees immediate feedback regardless of network latency.


Data Model / Entities

// ─── Poll Types ─────────────────────────────────────────────

type PollType =
  | "single-choice"
  | "multi-choice"
  | "ranked-choice"
  | "word-cloud"
  | "open-ended";

type PollStatus = "draft" | "open" | "locked" | "closed" | "archived";

type VotePrivacy = "anonymous" | "public" | "partially-anonymized";

interface PollOption {
  id: string; // UUID, stable across edits
  label: string; // Display text (max 200 chars)
  position: number; // Render order
  colorHint: string; // Hex color for chart segments
}

interface PollConfig {
  type: PollType;
  maxSelections: number; // For multi-choice: max options selectable
  allowVoteChange: boolean; // Can voter change after submitting
  showResultsBeforeClose: boolean; // Live results visible to voters
  timerDurationMs: number | null; // null = no timer
  privacy: VotePrivacy;
  requireAuth: boolean; // Require login vs anonymous
  antiCheat: AntiCheatConfig;
}

interface AntiCheatConfig {
  fingerprintEnabled: boolean; // Browser fingerprint dedup
  captchaOnSubmit: boolean; // CAPTCHA challenge before vote
  ipRateLimit: number; // Max votes per IP per minute
  cookieEnforcement: boolean; // First-party cookie dedup
}

interface Poll {
  id: string; // UUID
  slug: string; // URL-friendly identifier
  title: string; // Poll question text
  description: string | null; // Optional context
  options: PollOption[]; // 2–20 options
  config: PollConfig;
  status: PollStatus;
  createdBy: string; // User ID of creator
  createdAt: number; // Unix timestamp ms
  opensAt: number | null; // Scheduled open time
  closesAt: number | null; // Auto-close time (timer end)
  totalVotes: number; // Denormalized count
  embedAllowed: boolean; // Can be embedded externally
}

// ─── Vote Types ─────────────────────────────────────────────

interface VoteSubmission {
  pollId: string;
  optionIds: string[]; // Array for multi/ranked-choice
  rankings: number[] | null; // Position rankings for ranked-choice
  textResponse: string | null; // For open-ended
  nonce: string; // Idempotency key (UUID)
  timestamp: number; // Client timestamp
  fingerprint: string | null; // Browser fingerprint hash
}

interface VoteConfirmation {
  pollId: string;
  nonce: string; // Echoed back for client dedup
  accepted: boolean;
  rejectionReason: VoteRejection | null;
  serverTimestamp: number;
}

type VoteRejection =
  | "already-voted"
  | "poll-closed"
  | "poll-locked"
  | "invalid-option"
  | "rate-limited"
  | "captcha-required"
  | "fingerprint-duplicate";

// ─── Result Types ───────────────────────────────────────────

interface AggregatedResult {
  pollId: string;
  results: OptionResult[];
  totalVotes: number;
  lastUpdatedAt: number; // Server timestamp
  voterTurnout: number; // percentage 0-100
}

interface OptionResult {
  optionId: string;
  count: number;
  percentage: number; // Pre-computed server-side (0-100)
  rank: number; // Position by vote count
  delta: number; // Change since last broadcast (+/-)
}

// ─── WebSocket Message Types ────────────────────────────────

type WSInboundMessage =
  | { type: "subscribe"; pollId: string; role: "voter" | "presenter" }
  | { type: "vote"; payload: VoteSubmission }
  | { type: "ping" };

type WSOutboundMessage =
  | { type: "result-update"; payload: AggregatedResult }
  | {
      type: "poll-status-change";
      payload: { pollId: string; status: PollStatus };
    }
  | { type: "timer-sync"; payload: { serverTime: number; endsAt: number } }
  | { type: "vote-ack"; payload: VoteConfirmation }
  | { type: "pong"; serverTime: number }
  | { type: "error"; code: string; message: string };

// ─── Client State ───────────────────────────────────────────

interface PollClientState {
  poll: Poll | null;
  results: AggregatedResult | null;
  userVote: UserVoteState;
  connection: ConnectionState;
  timer: TimerState;
  animation: AnimationState;
}

interface UserVoteState {
  status: "idle" | "pending" | "optimistic" | "confirmed" | "rejected";
  selectedOptionIds: string[];
  submittedAt: number | null;
  rejectionReason: VoteRejection | null;
}

interface ConnectionState {
  status: "connecting" | "connected" | "reconnecting" | "disconnected";
  latencyMs: number;
  serverTimeOffsetMs: number; // serverTime - clientTime
  reconnectAttempt: number;
  lastMessageAt: number;
}

interface TimerState {
  isActive: boolean;
  remainingMs: number;
  serverEndsAt: number; // Absolute server timestamp
  localEndsAt: number; // Adjusted for clock offset
  driftCorrectionMs: number; // Cumulative drift correction
}

interface AnimationState {
  previousResults: OptionResult[] | null; // For interpolation
  transitionProgress: number; // 0-1 animation progress
  isAnimating: boolean;
}

Interface Definition (API)

REST Endpoints

MethodPathPurposeAuth
GET/api/polls/:idFetch poll metadata + current resultsOptional
POST/api/pollsCreate new pollRequired
PATCH/api/polls/:id/statusLock/unlock/close pollRequired (owner)
POST/api/polls/:id/votesSubmit vote (fallback for no-WS)Optional
GET/api/polls/:id/analyticsDetailed vote analyticsRequired (owner)
POST/api/polls/:id/resetReset all votesRequired (owner)

Vote Submission (REST fallback)

// POST /api/polls/:id/votes
// Headers: X-Idempotency-Key: <nonce>
// Headers: X-Fingerprint: <hash>

interface VoteRequest {
  optionIds: string[];
  rankings: number[] | null;
  textResponse: string | null;
  captchaToken: string | null;
}

// 200 OK
interface VoteResponse {
  accepted: boolean;
  nonce: string;
  updatedResults: AggregatedResult | null; // Included if showResultsBeforeClose
}

// 409 Conflict (duplicate vote)
interface VoteDuplicateError {
  error: "already-voted";
  existingVoteAt: number;
}

// 429 Too Many Requests
interface VoteRateLimitError {
  error: "rate-limited";
  retryAfterMs: number;
}

WebSocket Contract

// Connection: wss://ws.polls.example.com/poll/:id?role=voter&token=<optional>

// Client β†’ Server
// Subscribe on connect (implicit from URL params)
// Vote submission
{ "type": "vote", "payload": { "optionIds": ["opt-1"], "nonce": "uuid-v4", "fingerprint": "hash" } }

// Heartbeat (every 25s)
{ "type": "ping" }

// Server β†’ Client
// Initial state on subscribe
{ "type": "result-update", "payload": { "pollId": "...", "results": [...], "totalVotes": 4521 } }

// Batched result update (every 200ms during active voting)
{ "type": "result-update", "payload": { "results": [...], "totalVotes": 4583, "lastUpdatedAt": 1714500000000 } }

// Vote acknowledgement
{ "type": "vote-ack", "payload": { "nonce": "uuid-v4", "accepted": true, "rejectionReason": null } }

// Timer sync (every 5s during countdown)
{ "type": "timer-sync", "payload": { "serverTime": 1714500000000, "endsAt": 1714500030000 } }

// Poll status change (lock, close, etc.)
{ "type": "poll-status-change", "payload": { "pollId": "...", "status": "closed" } }

// Heartbeat response
{ "type": "pong", "serverTime": 1714500000000 }

Embed SDK API

// External usage:
// <script src="https://polls.example.com/sdk.js"></script>
// PollSDK.embed('#container', { pollId: 'abc', theme: 'dark' });

interface PollSDKOptions {
  pollId: string;
  theme: "light" | "dark" | "auto";
  locale: string;
  showBranding: boolean;
  onVote: (optionIds: string[]) => void; // Callback for host page
  onResults: (results: AggregatedResult) => void;
  containerStyle: Partial<CSSStyleDeclaration>;
}

Caching Strategy

Client-Side Caching

In-memory store (Zustand):

  • Active poll state: no eviction (single poll per view)
  • Previous result snapshots: ring buffer of last 10 updates for animation interpolation
  • User vote state: persisted to localStorage keyed by poll:${pollId}:vote
  • Size limit: 500KB total memory budget per tab

localStorage persistence:

KeyTTLPurpose
poll:${id}:vote30 daysRemember user's vote (prevents re-vote after refresh)
poll:${id}:fingerprintSessionDevice fingerprint for anti-cheat
poll:client-idPermanentStable client identifier for analytics
poll:clock-offset5 minutesServer time offset (refreshed on WS connect)

Service Worker strategy:

  • Cache-first for static assets (JS bundles, fonts, icons) β€” immutable with hash filenames
  • Network-first for poll data API (/api/polls/:id) β€” always prefer fresh
  • No SW caching for WebSocket traffic

CDN & Edge Caching

ResourceCache-ControlInvalidation
Voter bundle JSpublic, max-age=31536000, immutableContent-hash in filename
Presenter bundle JSpublic, max-age=31536000, immutableContent-hash in filename
HTML shellpublic, s-maxage=300, stale-while-revalidate=600Deploy-time purge
/api/polls/:id (GET)public, s-maxage=5, stale-while-revalidate=10Short TTL, live data
/api/polls/:id/analyticsprivate, no-storeAuthenticated, never cached
Embed SDK (sdk.js)public, max-age=3600Versioned URL (/v2/sdk.js)

Edge compute (Cloudflare Workers) handles:

  • Geolocation injection for analytics (no client API call needed)
  • Rate limiting at edge (before reaching origin)
  • WebSocket upgrade routing to nearest WS server

Cache Coherence

Stale data detection:

  • Every WS message includes lastUpdatedAt timestamp
  • Client compares against local state timestamp; if server timestamp is older (duplicate/out-of-order), message is discarded
  • Sequence numbers on WS messages detect gaps; missing sequences trigger full state refetch

Cross-tab consistency:

  • BroadcastChannel('poll-votes') synchronizes vote state across tabs
  • If user votes in one tab, all tabs for the same poll reflect immediately
  • Leader election: first tab to connect WS becomes the "leader"; other tabs receive updates via BroadcastChannel (reduces connection count)

Optimistic update reconciliation:

  • On vote submission: immediately increment local count, mark vote as optimistic
  • On server ACK (vote-ack): transition to confirmed, next result-update from server becomes authoritative
  • On server NACK: roll back local count, show error, transition to rejected
  • If result-update arrives before ACK: merge by keeping the higher of (local optimistic count, server count) for the voted option

Cache versioning on deploy:

  • HTML shell includes <meta name="app-version" content="v2.3.1">
  • WS server sends version in handshake; if mismatch, client shows "Update available" banner
  • Hard refresh forced only on major version bumps

Rendering & Performance Deep Dive

Critical Rendering Path

Tier 1 (0–500ms): HTML shell + critical CSS (inline) + loading skeleton. Skeleton shows poll question placeholder and option bars. Size: < 15KB total.

Tier 2 (500ms–1.5s): Voter bundle loads. WebSocket connects. Poll data arrives. First meaningful paint: question text + option buttons rendered. Size: < 80KB gzipped JS.

Tier 3 (1.5s–3s): Chart library (for result visualization) loads on demand after vote submission or if showResultsBeforeClose is true. Animation engine initializes. Size: < 40KB gzipped.

Tier 4 (deferred): Analytics panel (presenter only), word cloud renderer, export utilities. Loaded on interaction. Size: varies.

Core Web Vitals Targets

MetricTargetStrategy
LCP< 1.2sInline critical CSS, preload fonts, CDN-served shell
INP< 100msVote submission is async; UI updates are synchronous state changes
CLS< 0.05Fixed-height option containers, skeleton matches final layout
FCP< 600msInline HTML shell with question text placeholder
TTFB< 100msEdge-cached HTML, no server rendering

Result Animation Engine

// Animation uses requestAnimationFrame with eased interpolation
// between previous and current result states

function animateResults(
  from: OptionResult[],
  to: OptionResult[],
  duration: number = 400,
): void {
  const startTime = performance.now();

  function frame(now: number): void {
    const elapsed = now - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const eased = easeOutCubic(progress);

    for (let i = 0; i < to.length; i++) {
      const fromPct = from[i]?.percentage ?? 0;
      const toPct = to[i].percentage;
      const currentPct = fromPct + (toPct - fromPct) * eased;
      renderBar(to[i].optionId, currentPct);
    }

    if (progress < 1) {
      requestAnimationFrame(frame);
    }
  }

  requestAnimationFrame(frame);
}

function easeOutCubic(t: number): number {
  return 1 - Math.pow(1 - t, 3);
}

Key decisions:

  • Result bars use CSS transform: scaleX() for width animation (GPU-composited, no layout thrash)
  • Percentage labels use will-change: contents sparingly, updated via direct DOM manipulation for 60fps
  • Word cloud uses canvas (not DOM) for 200+ word rendering
  • Ranked-choice results use FLIP animation for reordering

Bundle Optimization

DependencySizeLoading Strategy
Zustand2KBVoter bundle (always)
Chart.js (results)35KBDynamic import on first result display
Canvas word cloud12KBDynamic import for word-cloud polls only
CAPTCHA SDK25KBDynamic import on vote if captchaOnSubmit
Reconnecting WebSocket3KBVoter bundle (always)
Fingerprint.js8KBDynamic import if fingerprintEnabled

Total voter critical path: ~80KB (Zustand + WS lib + UI components + poll logic).


Security Deep Dive

Threat Model

ThreatAttack VectorImpactMitigation
Vote stuffingAutomated scripts submitting thousands of votesCorrupted results, loss of trustFingerprinting + rate limiting + CAPTCHA + nonce dedup
Result manipulationMan-in-the-middle modifying WS messagesViewers see false resultsWSS (TLS) + message signing (HMAC) + server-authoritative state
Poll takeoverCSRF on presenter endpoints (lock/close/reset)Poll disrupted during live eventSameSite cookies + CSRF token + re-auth for destructive actions
Timer desync exploitClient clock manipulation to vote after deadlineUnfair late votesServer-authoritative close time; votes rejected if serverTime > closesAt
Embed clickjackingWrapping embed in invisible overlay to harvest votesInvoluntary vote submissionX-Frame-Options: ALLOW-FROM allowlist + framebusting for non-allowlisted hosts

Anti-Cheating

Multi-layer deduplication:

Layer 1: Nonce (idempotency key) β†’ prevents exact replay
Layer 2: Session cookie (httpOnly, SameSite=Strict) β†’ one vote per session
Layer 3: Browser fingerprint (canvas + WebGL + fonts + timezone) β†’ one vote per device
Layer 4: IP rate limiting (10 votes/min/IP) β†’ prevents botnet spray
Layer 5: CAPTCHA challenge (configurable) β†’ blocks automated tools

Fingerprint generation (client-side):

async function generateFingerprint(): Promise<string> {
  const components = [
    navigator.userAgent,
    navigator.language,
    screen.width + "x" + screen.height,
    new Date().getTimezoneOffset().toString(),
    await getCanvasFingerprint(),
    await getWebGLFingerprint(),
    navigator.hardwareConcurrency?.toString() ?? "unknown",
  ];

  const raw = components.join("|");
  const buffer = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(raw),
  );
  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

The fingerprint is sent with the vote but never stored raw β€” only the hash is compared server-side.

Rate Limiting

ScopeLimitWindowAction on Exceed
Per IP10 votes60s429 + retry-after header
Per fingerprint1 vote per pollPoll lifetime409 Conflict
Per session1 vote per pollSession lifetime409 Conflict
WS messages per connection30 messages10sConnection throttled (buffered)
Poll creation per user20 polls24h429 + upgrade prompt

Rate limiting is enforced at the edge (Cloudflare) for IP-level and at the application layer for session/fingerprint-level.

Abuse Prevention

  • Tor/VPN detection: Flag votes from known exit nodes; require CAPTCHA for flagged IPs
  • Behavioral analysis: Votes submitted < 500ms after poll load are flagged (bot speed)
  • Referrer validation for embeds: Embedded polls only accept votes from allowlisted domains
  • Vote velocity monitoring: If vote rate exceeds 3Γ— expected for audience size, auto-enable CAPTCHA
  • Presenter notification: Real-time alert to presenter when suspicious activity detected
πŸ’‘

For anonymous polls, the system faces a fundamental tension: stronger anti-cheat requires more device identification, which conflicts with voter privacy. The resolution is configurable tiers: "casual" (cookie-only), "standard" (cookie + fingerprint), and "strict" (fingerprint + CAPTCHA + auth). Presenters choose the tier when creating the poll.


Scalability & Reliability

Scalability Patterns

WebSocket fan-out architecture:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                               β”‚
β”‚  Vote Ingest ──→ Redis INCR ──→ Aggregation Worker           β”‚
β”‚                                      β”‚                        β”‚
β”‚                               Redis Pub/Sub                   β”‚
β”‚                              β•±       β”‚       β•²                β”‚
β”‚                    WS Server 1   WS Server 2   WS Server N   β”‚
β”‚                    (25K conn)   (25K conn)    (25K conn)      β”‚
β”‚                                                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • Each WS server handles 25K–50K connections
  • Aggregation worker publishes to Redis Pub/Sub channel poll:{id}:results
  • All WS servers subscribe to the channel and broadcast to their connected clients
  • Horizontal scaling: add WS servers, connections distributed by load balancer

Connection pooling for embeds:

  • Embedded polls on high-traffic pages (e.g., news site with 1M readers) use shared WebSocket connections
  • One WS connection per browser tab, multiplexed across multiple poll embeds on the same page
  • SDK manages connection lifecycle: connect on first poll visible, disconnect after 30s of no visible polls

Failure Handling

Failure ModeDetectionUser ExperienceRecovery
WebSocket disconnectonclose event"Reconnecting..." banner, results freezeExponential backoff: 1s, 2s, 4s, 8s, max 30s. Jitter Β±500ms. Full state refetch on reconnect.
Vote submission timeoutNo ACK within 5sVote button shows spinner, then "Retry"Client retries with same nonce (idempotent). Max 3 retries. After 3: show "Submit failed, try again."
Server returns 5xxHTTP status codeError banner: "Something went wrong"Retry with backoff. Fall back to REST polling (GET results every 2s) if WS is down.
Clock drift detectedTimer mismatch > 2s from server syncTimer auto-corrects (smooth adjustment, no jump)Re-sync on next timer-sync message. If drift > 5s, hard reset timer.
Stale results (no update > 10s)Missing heartbeat during active poll"Results may be delayed" indicatorSend explicit ping; if no pong in 3s, reconnect.

Resilience Patterns

  • Outbox pattern: Votes are stored in sessionStorage before submission. If submission fails and user navigates away, the vote is retried on next page load.
  • Idempotency keys: Every vote includes a UUID nonce. Server deduplicates. Safe to retry indefinitely.
  • Circuit breaker: If 5 consecutive WS reconnect attempts fail within 60s, fall back to REST polling mode (GET every 2s). Display "Live updates unavailable" notice.
  • Graceful degradation tiers:
Connection QualityExperience
WebSocket healthyReal-time results, 200ms batches
WebSocket flappingResults via REST polling every 2s
OfflineShow last known results + "Offline" badge, queue vote
No JavaScriptProgressive enhancement: form POST submission, page reload for results

Offline & Reconnect

// Reconnection state machine
type ConnectionPhase =
  | "connected" // WS open, messages flowing
  | "reconnecting" // WS closed, attempting reconnect
  | "polling-fallback" // WS failed, using REST
  | "offline"; // No network detected

// On reconnect, request full state to reconcile
// Server responds with latest AggregatedResult
// Client compares sequence numbers and fast-forwards

Accessibility Deep Dive

Semantic Structure

<!-- Voter view landmark structure -->
<main role="main" aria-labelledby="poll-title">
  <h1 id="poll-title">Which framework do you prefer?</h1>

  <form
    role="radiogroup"
    aria-labelledby="poll-title"
    aria-describedby="poll-instructions"
  >
    <p id="poll-instructions" class="sr-only">
      Select one option then press Submit Vote.
    </p>

    <label>
      <input
        type="radio"
        name="vote"
        value="opt-1"
        aria-describedby="opt-1-pct"
      />
      React
    </label>
    <span id="opt-1-pct" aria-live="polite">42%</span>

    <!-- ... more options ... -->
  </form>

  <button type="submit" aria-busy="false">Submit Vote</button>

  <section aria-label="Results" aria-live="polite" aria-atomic="true">
    <!-- Result bars announced on update -->
  </section>

  <div role="timer" aria-live="assertive" aria-label="Time remaining">
    <!-- Countdown announced at 30s, 10s, 5s, 0s -->
  </div>
</main>

Keyboard Navigation

KeyContextAction
TabOptions listMove between options
Space/EnterFocused optionSelect/deselect option
Arrow Up/DownRadio groupNavigate options (native radio behavior)
EnterSubmit button focusedSubmit vote
EscapeAnyClose result overlay (presenter mode)
1–9Options listQuick-select option by number

Screen Reader Announcements

  • On poll load: "Poll: [title]. [N] options available. [Timer: X seconds remaining / No time limit]."
  • On vote submission: "Vote submitted for [option label]."
  • On vote rejection: "Vote not accepted. Reason: [reason]."
  • On result update: Announced only at meaningful thresholds (every 10% change or when a new option takes the lead). Avoids announcement storm during rapid voting.
  • On timer milestones: "30 seconds remaining." / "10 seconds remaining." / "5, 4, 3, 2, 1." / "Poll closed."
  • On poll status change: "Poll is now [locked/closed/open]."

Motion & Contrast

  • prefers-reduced-motion: Disable bar animation, use instant transitions. Disable word cloud float animation.
  • Result bars use both color AND pattern fills for colorblind accessibility.
  • Minimum contrast: 4.5:1 for all text, 3:1 for large text (option labels > 18px).
  • Focus indicators: 3px solid outline with 2px offset, high-contrast color.
  • Touch targets: All option buttons β‰₯ 48Γ—48px. Submit button β‰₯ 48Γ—120px.

Monitoring & Observability

Client-Side Metrics

MetricCollectionAlert Threshold
Vote submission latency (p50, p95, p99)Custom timing via performance.markp95 > 2s
Time to first result displayNavigation timing + custom mark> 3s
WebSocket connection success rateConnect attempts / successful connects< 95%
WebSocket reconnection frequencyCounter per session> 5 reconnects / 5min
Result animation frame dropsrequestAnimationFrame timing gaps > 20ms> 10% frames dropped
Vote rejection rateClient-side counter> 5% of submissions
Client error ratewindow.onerror + boundary catches> 1% of sessions
Bundle load timeResource Timing APIVoter bundle > 2s

Error Tracking

  • Unhandled promise rejections captured via window.addEventListener('unhandledrejection')
  • React error boundaries at: App level, Result chart level, Vote form level
  • Source maps uploaded to error tracking service (Sentry) on deploy
  • Errors grouped by: poll ID, error type, browser, connection state
  • Alert: > 50 errors/min across all clients for a single poll

Logging & Tracing

// Every WS message includes a correlationId for server-side tracing
interface ClientLog {
  timestamp: number;
  sessionId: string;
  pollId: string;
  event: string; // 'vote-submitted' | 'ws-connected' | 'result-received' | ...
  latencyMs: number | null;
  metadata: Record<string, string | number>;
}

// Sampled at 10% for normal events, 100% for errors

Day-1 Launch Dashboard

PanelVisualizationPurpose
Active WebSocket connectionsReal-time gaugeCapacity monitoring
Votes per secondTime-series line chartTraffic spike detection
Vote submission p95 latencyTime-series with threshold linePerformance regression
WS reconnection ratePercentage gaugeConnection stability
Error rate by typeStacked bar chartIssue classification
Client JS errorsCounter with trendDeployment regression
Result broadcast lagHistogramFan-out health
Geographic vote distributionHeatmapCDN/edge coverage gaps

Alerting Rules

ConditionSeverityAction
Error rate > 1% for 2 minWarningSlack notification
Error rate > 5% for 1 minCriticalPagerDuty page
WS connections drop > 20% in 30sCriticalPagerDuty page
Vote p95 latency > 3s for 5 minWarningSlack notification
Zero votes received for active poll > 60sCriticalPagerDuty page
Bundle size exceeds 100KB (CI)BlockingDeploy rejected

Trade-offs

DecisionProCon
WebSocket over SSE for result streamingBidirectional β€” vote submission + result receive on one connection; lower overhead per messageMore complex infrastructure (sticky sessions, connection management); not cacheable by CDN
200ms batched broadcasts over per-vote fan-outReduces outbound messages from 170M/s to 500K/s; smooths animationAdds 0–200ms latency to result visibility; voters may perceive slight delay
Client-side fingerprinting for anti-cheatNo login required for anonymous polls; blocks casual fraudPrivacy concern; fingerprint entropy varies by browser (Safari limits); false positives possible
Zustand over Redux for state management2KB vs 7KB; simpler API for single-poll state; no boilerplateLess ecosystem tooling; no Redux DevTools time-travel (use Zustand middleware instead)
Canvas for word cloud over DOMHandles 200+ words at 60fps; no layout thrashNot accessible by default (need aria-label overlay); harder to style; no CSS transitions
REST fallback when WS unavailableGuarantees votes are never lost; works behind corporate proxies blocking WS2s polling interval means stale results; higher server load from polling
Server-authoritative timer over client timerPrevents clock manipulation attacks; fair for all votersRequires NTP-lite sync protocol; visible timer correction if drift > 1s
Separate voter/presenter bundles over single SPAVoter bundle stays < 80KB; presenter gets rich tooling without penalizing votersTwo deployment artifacts; shared types need separate package or build-time extraction
Cookie-based vote dedup over auth-requiredFrictionless voting (no signup); good for casual pollsCleared cookies = revote possible; weaker guarantee than auth; GDPR consent needed

What Great Looks Like

A senior answer covers:

  • Correct WebSocket connection lifecycle with reconnection logic
  • Optimistic vote updates with rollback on rejection
  • Basic anti-duplicate (session cookie or localStorage flag)
  • Result bar animation using CSS transitions or requestAnimationFrame
  • Separation of voter and presenter concerns

A staff answer additionally:

  • Designs the 200ms batching strategy with clear capacity math
  • Implements server-time synchronization for countdown timers (NTP-lite)
  • Architects the multi-layer anti-cheat with configurable tiers
  • Specifies cross-tab coordination via BroadcastChannel
  • Plans the embed isolation model (shadow DOM vs iframe trade-off)
  • Defines graceful degradation tiers (WS β†’ REST polling β†’ offline)
  • Designs the animation interpolation engine for smooth result transitions

A principal answer additionally:

  • Models the full fan-out topology and its scaling limits (Redis Pub/Sub channel per poll, WS server fleet sizing)
  • Designs the privacy-preserving anti-cheat pipeline where fingerprints are never stored raw
  • Specifies the cache coherence protocol including sequence numbers, gap detection, and state reconciliation
  • Addresses the fundamental tension between anti-fraud and voter privacy with configurable trust tiers
  • Plans monitoring for a 100K concurrent viewer scenario with specific alert thresholds and degradation triggers

Key Takeaways

  • Batch result broadcasts in 200ms windows to reduce fan-out from millions of messages to hundreds of thousands per second β€” this single decision determines whether the system survives peak load.
  • Use a three-phase vote state model (pending β†’ optimistic β†’ confirmed) so voters see instant feedback regardless of network latency while maintaining server-authoritative consistency.
  • Synchronize countdown timers via server-time offset calculated on WebSocket handshake β€” never trust Date.now() for cross-device fairness.
  • Separate voter and presenter bundles at the build level β€” voters need < 80KB for a fast mobile experience; presenters need chart libraries and analytics that would triple the voter bundle.
  • Layer anti-cheat progressively (cookie β†’ fingerprint β†’ CAPTCHA β†’ auth) and let poll creators choose the tier based on their trust requirements and friction tolerance.
  • Design for WebSocket failure as a normal state β€” implement automatic fallback to REST polling with clear UI indicators so users never lose functionality, only freshness.
  • Animate results with GPU-composited transforms (scaleX on bars, canvas for word clouds) β€” DOM-based animations drop frames when 100K votes arrive in 30 seconds.
  • Use BroadcastChannel for cross-tab deduplication β€” both to prevent multiple WebSocket connections and to synchronize vote state across tabs without server round-trips.