Real-Time Voting/Polls Implementation on Website
Real-time polls are not just "change the button color after a click." The essence is that all participants see the results change simultaneously without page reload. Conference websites, interactive learning platforms, live broadcasts, corporate voting—everywhere there's one technical need: broadcast changes to all connected clients.
Choosing the Transport
Two mechanisms suit voting: Server-Sent Events (SSE) and WebSocket. A third option—polling—we won't consider: 1 request per second on 500 concurrent users = 500 req/s load just to check "nothing changed."
SSE—unidirectional stream from server to client. For polls, this is enough: a vote is sent via regular POST, the result arrives through the SSE stream.
WebSocket—bidirectional channel. Justified if you need immediate feedback (animation "your vote is counted" without a new HTTP request) or additional interactive elements in the same session.
For most poll-enabled websites, SSE is simpler to implement and cheaper infrastructure-wise—no sticky sessions or separate WebSocket server needed.
Data Schema
CREATE TABLE polls (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
is_multiple BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
ends_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE poll_options (
id BIGSERIAL PRIMARY KEY,
poll_id BIGINT NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
position SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE poll_votes (
id BIGSERIAL PRIMARY KEY,
option_id BIGINT NOT NULL REFERENCES poll_options(id),
user_id BIGINT REFERENCES users(id),
ip INET,
voted_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(option_id, user_id) -- one vote per option per user
);
Aggregation is calculated via materialized view or direct COUNT—depends on voting frequency. Under peak load (live broadcast, 5000+ participants), it's better to store counters separately and increment via Redis:
HINCRBY poll:42:counts 1 1 # option 1 received +1 vote
SSE Endpoint on Laravel
Route::get('/api/polls/{poll}/stream', function (Poll $poll) {
return response()->stream(function () use ($poll) {
while (true) {
if (connection_aborted()) break;
$counts = PollVote::selectRaw('option_id, COUNT(*) as votes')
->whereIn('option_id', $poll->options->pluck('id'))
->groupBy('option_id')
->pluck('votes', 'option_id');
$data = json_encode(['counts' => $counts, 'ts' => now()->timestamp]);
echo "data: {$data}\n\n";
ob_flush();
flush();
sleep(2);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no', // disables buffering in Nginx
]);
});
X-Accel-Buffering: no—required header when using Nginx as a proxy, otherwise data will accumulate in the buffer and not be sent to the client in real time.
Client Side
const pollId = 42;
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.onmessage = (event) => {
const { counts } = JSON.parse(event.data);
updateBars(counts);
};
source.onerror = () => {
// Browser automatically reconnects after 3s—this is the default EventSource behavior
console.warn('SSE reconnecting...');
};
function updateBars(counts) {
const total = Object.values(counts).reduce((a, b) => a + Number(b), 0);
document.querySelectorAll('[data-option-id]').forEach(el => {
const id = el.dataset.optionId;
const pct = total > 0 ? Math.round((counts[id] || 0) / total * 100) : 0;
el.querySelector('.bar').style.width = pct + '%';
el.querySelector('.label').textContent = pct + '%';
});
}
Sending a Vote
async function vote(optionId) {
const resp = await fetch(`/api/polls/${pollId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ option_id: optionId }),
});
if (resp.status === 409) {
showMessage('You already voted');
}
}
Vote duplication is prevented on two levels: UNIQUE(option_id, user_id) in the database and controller validation before insertion.
Anonymous Voting
When users are not authenticated—protection via IP + fingerprint. Fingerprint is generated on the frontend (library fingerprintjs) and sent in a header. It's not absolute protection, but sufficient for most cases.
$fingerprint = $request->header('X-Client-Fingerprint');
$alreadyVoted = PollVote::where('poll_id', $poll->id)
->where(function ($q) use ($request, $fingerprint) {
$q->where('ip', $request->ip())
->orWhere('fingerprint', $fingerprint);
})->exists();
Scaling During Peak Load
A PHP application with SSE holds a connection open for the duration of streaming. 1000 concurrent users = 1000 PHP workers. This is expensive.
Solution: offload broadcast via Pusher or Laravel Echo Server (socket.io). Then the SSE controller is no longer needed—the client subscribes to a channel, the server publishes the poll.updated event to Redis, Laravel Echo broadcasts it to all subscribers.
// After recording a vote
broadcast(new PollUpdated($poll->id, $counts))->toOthers();
Echo.channel(`poll.${pollId}`)
.listen('PollUpdated', ({ counts }) => updateBars(counts));
This architecture supports hundreds of thousands of connections on a single Node.js process.
Timeline
- Basic voting (SSE, authenticated users): 2–3 days
- Anonymous voting with anti-duplicate: +1 day
- Multiple-choice polls + voting history: +1 day
- Scalable version via Pusher/Echo: +2 days
- Admin interface for poll management: 2–3 days







