How would you build a poll/voting system with real-time results in React?
Advertisement
🧩 Scenario
🧠 Architecture Walkthrough
Why optimistic voting requires a two-phase state update
When a user clicks a poll option, the naive approach is to wait for the server to respond before updating the UI. In a real-time poll with thousands of concurrent users, that creates a visible lag that breaks the sense of immediacy the feature promises.
The solution is to apply the vote locally the moment the click happens incrementing the chosen option's count and marking the user as having voted then send the vote to the server in the background. If the server confirms, nothing changes.
If the server rejects (duplicate detection, network error, or server-side validation failure), the code must undo the exact changes it made: decrement the option count and remove the userVotes entry for that poll.
The tricky part is that other votes may have arrived from the WebSocket in the intervening milliseconds. Rollback must be surgical a targeted decrement, not a full state reset otherwise external votes received while the request was in flight disappear from the screen.
How the WebSocket message dispatch drives UI consistency
The demo uses a MockWebSocket that produces two message types: vote:confirmed for the current user's vote acknowledgment, and external:vote when another user votes.
These two paths are handled in separate switch cases in ws.onmessage. The vote:confirmed path clears the votingInProgress flag and logs the activity. The external:vote path uses a functional setPolls update to immutably add one vote to the correct option on the correct poll.
Using the functional form of setPolls - prev => prev.map(...) is critical here: it closes over the latest state, not the snapshot captured when the effect was registered. If you used the closure variable directly, concurrent external votes could overwrite each other due to stale state.
Why the animated counter is implemented with requestAnimationFrame rather than CSS transitions
CSS transitions can animate visual properties like width smoothly, and the progress bar uses exactly that pattern a transition: 'width 0.6s ease-out' on the bar div. But the vote count number cannot be transitioned with CSS alone because it is a discrete integer rendered as text, not a visual property.
The useAnimatedCounter hook solves this by running an interpolation loop via requestAnimationFrame. It captures the start value, the end value, and the start time, then on each frame computes a progress ratio using an ease-out cubic curve.
The key design decision is seeding displayValue from the current displayed number rather than from zero: when votes arrive in quick succession, each new animation should continue from wherever the counter visually is, not restart from scratch. The isAnimating flag triggers a blue color on the number during the transition, giving users a clear signal that fresh data arrived.
💡 Key Code Explained
const useAnimatedCounter = (value, duration = 300) => {
const [displayValue, setDisplayValue] = useState(value);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
if (value !== displayValue) {
setIsAnimating(true);
const start = displayValue;
const end = value;
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
const current = Math.round(start + (end - start) * easeOut);
setDisplayValue(current);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setIsAnimating(false);
}
};
requestAnimationFrame(animate);
}
}, [value, displayValue, duration]);
return { displayValue, isAnimating };
};
The most important detail here is that start is captured as displayValue at the moment the effect runs, not as a constant. This means if the incoming value changes again before the animation finishes, the next effect run will pick up wherever the counter currently is and animate forward from there preventing jarring jumps back to zero.
The cubic ease-out formula 1 - (1 - progress)^3 decelerates the counter as it approaches the target, which matches human perception of "settling" better than a linear interpolation. A junior developer would likely reach for setInterval here, which introduces drift because the delay accumulates; requestAnimationFrame ties the update rate to the screen's refresh cycle.
const handleVote = async (optionId) => {
if (hasVoted || votingInProgress[currentPollId]) return;
setUserVotes((prev) => ({ ...prev, [currentPollId]: optionId }));
setVotingInProgress((prev) => ({ ...prev, [currentPollId]: true }));
setPolls((prev) =>
prev.map((poll) =>
poll.id === currentPollId
? {
...poll,
options: poll.options.map((opt) =>
opt.id === optionId ? { ...opt, votes: opt.votes + 1 } : opt,
),
}
: poll,
),
);
try {
if (wsRef.current?.readyState === 1) {
wsRef.current.send(
JSON.stringify({
type: 'vote',
pollId: currentPollId,
optionId,
userId: 'current-user',
}),
);
}
} catch (error) {
setUserVotes((prev) => {
const newVotes = { ...prev };
delete newVotes[currentPollId];
return newVotes;
});
setPolls((prev) =>
prev.map((poll) =>
poll.id === currentPollId
? {
...poll,
options: poll.options.map((opt) =>
opt.id === optionId ? { ...opt, votes: opt.votes - 1 } : opt,
),
}
: poll,
),
);
setVotingInProgress((prev) => ({ ...prev, [currentPollId]: false }));
}
};
This function executes three state mutations synchronously before the async send: it records the user's vote, marks voting as in-progress, and increments the option count. All three must happen together to keep the UI consistent if you only incremented the count but didn't mark hasVoted, a fast second click could queue a duplicate.
The rollback in the catch block mirrors these mutations in reverse: it removes the userVotes entry and decrements the option count. Notice that the rollback does not reset to a stored snapshot; it decrements by exactly 1, which correctly handles the case where external votes arrived and changed the count while the request was in flight.
⚖️ Tradeoffs
| Approach | Pro | Con |
|---|---|---|
| Optimistic update + rollback (chosen) | Instant perceived feedback; rollback is targeted and handles concurrent external votes | Rollback logic must be mirrored exactly; subtle bugs if multiple optimistic mutations stack |
| Wait for server confirmation before updating UI | Simple, always correct | Visibly laggy at 100ms+ latency; feels broken in live poll context |
| Server-sent events instead of WebSocket | Simpler server setup for read-only streams; HTTP/2 multiplexed | Unidirectional; vote sends still require a separate REST/HTTP call |
| Polling REST endpoint every N seconds | Zero extra infra; works through proxies and firewalls | Stale by up to N seconds; wastes bandwidth; N seconds of lag kills "real-time" feel |
🎯 What Interviewers Actually Check
- Knows that the rollback must decrement rather than restore from a snapshot, because concurrent external votes can change the total in the interim
- Mentions that
readyState === 1must be checked before everyws.send()call, not just once at connection time - Recognises that
userVotesis keyed bypollIdvoting on one poll must not affect another poll's voting state - Can explain why the
votingInProgressflag is needed in addition tohasVotedthe window between optimistic update and server confirmation must block a second click - Raises the concern that client-side duplicate-vote prevention is cosmetic only; the server must also reject duplicate votes by user ID
❓ Follow-Up Questions
- The rollback decrements the count by 1. What happens if the server silently drops the request (no error thrown) and the vote never actually registers? How would you detect and reconcile that divergence?
- Two users vote simultaneously on the same option. Both clients apply optimistic updates. The server processes them serially. How does the server communicate the authoritative total back, and how does the client merge that with its current local state?
- If you wanted to write a test for
handleVote, what would you mock and what exact state transitions would you assert on success and on failure? - The activity feed keeps the last 50 entries in memory. If this poll widget runs on a page for 12 hours with a high-traffic event, what other memory pressure points exist in the component and how would you address them?
- Your product manager asks to allow users to change their vote within 30 seconds of voting. How does that change the optimistic update model, the rollback logic, and the server-side deduplication?
🎮 Live Demo
📝 Summary
A real-time poll requires three tightly coordinated systems: a WebSocket channel that broadcasts external votes to all connected clients, an optimistic update model that applies the user's own vote instantly with a targeted rollback on failure, and an animation layer that interpolates visual changes rather than jumping between discrete values.
The most common mistake in interview implementations is conflating the hasVoted flag (permanent after confirmation) with the votingInProgress flag (temporary during the server round-trip) both are needed to prevent double-voting during the async window.
At production scale, the server must be the source of truth for vote counts because optimistic updates from multiple clients will diverge; periodic reconciliation or authoritative totals pushed from the server via the same WebSocket channel are standard approaches. The CSS transition on width and the requestAnimationFrame counter work together to signal freshness to the user without jarring visual resets.
WebSockets vs Server-Sent Events (SSE)?
Use WebSockets for two-way communication. Use SSE if only server → client updates are needed.
How do you prevent double voting?
Use auth tokens, device fingerprinting, or server-side vote rate limiting. Client-side checks alone aren't secure.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement