Real-Time Poll Widget
Challenge: "Design an embeddable poll widget that shows live results as votes come in."
Requirements
Functional
- Display question with multiple choice options
- Vote by clicking an option (one vote per user)
- Show live results with animated bar chart
- Real-time vote count updates via WebSocket/SSE
- Embeddable in third-party sites via iframe or web component
- Support multiple simultaneous polls
Non-Functional
- Sub-100ms optimistic vote feedback
- Handle 10,000+ concurrent voters
- Prevent duplicate votes (fingerprinting + auth)
- < 50KB widget bundle size
- Accessible (keyboard, screen reader)
Architecture
âââââââââââââââââââââââââââââââââââââââââââ
â Host Page â
â âââââââââââââââââââââââââââââââââââââ â
â â Poll Widget (iframe / WC) â â
â â ââââââââââââââââââââââââââââ â â
â â â Question: "Best FW?" â â â
â â â â React [âââââ] 42% â â â
â â â â Vue [âââââ] 31% â â â
â â â â Angular [âââââ] 17% â â â
â â â â Svelte [âââââ] 10% â â â
â â â Total: 1,247 votes â â â
â â ââââââââââââââââââââââââââââ â â
â âââââââââââââââââââââââââââââââââââââ â
âââââââââââââââââââââââââââââââââââââââââââ
â postMessage / SSE
âââââââââââââââââââââââââââââââââââââââââââ
â Poll API Server â
â WebSocket/SSE â vote broadcast â
â REST â create poll, cast vote â
â Redis â vote counts, dedup â
âââââââââââââââââââââââââââââââââââââââââââData Model
interface Poll {
id: string;
question: string;
options: PollOption[];
totalVotes: number;
createdAt: string;
expiresAt?: string;
settings: {
allowMultiple: boolean;
showResultsBeforeVote: boolean;
requireAuth: boolean;
};
}
interface PollOption {
id: string;
text: string;
votes: number;
percentage: number;
}
interface VoteEvent {
type: 'vote';
pollId: string;
optionId: string;
newCount: number;
totalVotes: number;
}Key Design Decisions
1. Real-Time Update Strategy
Server-Sent Events (SSE) over WebSocket for this use case:
- Votes are unidirectional (server â client broadcast)
- SSE auto-reconnects, works through HTTP proxies
- Lower overhead than WebSocket for one-way data
function usePollUpdates(pollId: string) {
const [poll, setPoll] = useState<Poll | null>(null);
useEffect(() => {
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.addEventListener('update', (event) => {
const data: VoteEvent = JSON.parse(event.data);
setPoll(prev => prev ? {
...prev,
totalVotes: data.totalVotes,
options: prev.options.map(opt =>
opt.id === data.optionId
? { ...opt, votes: data.newCount, percentage: (data.newCount / data.totalVotes) * 100 }
: { ...opt, percentage: (opt.votes / data.totalVotes) * 100 }
),
} : null);
});
return () => source.close();
}, [pollId]);
return poll;
}2. Optimistic Vote
function castVote(pollId: string, optionId: string) {
setPoll(prev => {
const totalVotes = prev.totalVotes + 1;
return {
...prev,
totalVotes,
options: prev.options.map(opt => ({
...opt,
votes: opt.id === optionId ? opt.votes + 1 : opt.votes,
percentage: ((opt.id === optionId ? opt.votes + 1 : opt.votes) / totalVotes) * 100,
})),
};
});
setHasVoted(true);
fetch(`/api/polls/${pollId}/vote`, {
method: 'POST',
body: JSON.stringify({ optionId }),
}).catch(() => {
setHasVoted(false);
refetchPoll();
});
}3. Embeddability
Option A: iframe (strongest isolation)
<iframe src="https://polls.example.com/embed/abc123" width="400" height="300" />Option B: Web Component (lighter weight)
<script src="https://polls.example.com/widget.js"></script>
<poll-widget poll-id="abc123"></poll-widget>4. Anti-Fraud
- Authenticated users: One vote per user ID
- Anonymous: Fingerprint hash (IP + User-Agent + canvas fingerprint), stored in Redis with TTL
- Rate limiting: Max 1 vote per poll per fingerprint per 24h
- Client-side: Set cookie/localStorage flag (easily bypassed, but prevents casual duplicates)
Animated Result Bars
.result-bar {
height: 40px;
background: var(--poll-color);
border-radius: 4px;
transition: width 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.result-bar { transition: none; }
}Accessibility
- Options are a
role="radiogroup"before voting - After voting, results show with
aria-valuenowon bars - Live vote count announced via
aria-live="polite" - Full keyboard navigation (Tab between options, Enter to vote)
- Color is not the only differentiator (percentages shown as text)