Real-Time Voting and Polls for Your Website
Imagine: a conference with 10,000 participants, organizers launch a vote, and results update every 30 seconds via regular AJAX polling. Users complain about delays, the server crashes under 10,000 requests per second. Sound familiar? Choosing real-time voting solves two problems: instant result delivery and reduced server load. We have implemented such systems for 30+ projects — from corporate surveys to large-scale live streams.
Why SSE Instead of Polling?
Polling means N requests per second, where N is the number of users. With 500 users — 500 req/s wasted. SSE or WebSocket give one connection per user, through which the server sends data only when it changes. For voting, two mechanisms work: SSE (Server-Sent Events) and WebSocket. A third option — polling — we do not consider: 1 request per second for 500 concurrent users is 500 req/s load just to check "nothing changed." SSE is a unidirectional stream from server to client, described in the EventSource specification. For polls this is sufficient: the vote is sent via ordinary POST, the result arrives via SSE. WebSocket is justified if you need immediate feedback (animation "your vote accepted") or additional interactive elements.
| Parameter | SSE | WebSocket |
|---|---|---|
| Direction | Unidirectional (server → client) | Bidirectional |
| Implementation complexity | Low (native EventSource) | Medium (requires WebSocket server) |
| Infrastructure | No sticky sessions | Requires sticky session or separate server |
| Performance | Up to 10,000 connections per PHP worker | Up to 100,000 connections on Node.js |
| Browser support | All modern, except IE | All modern |
How to Prevent Duplicate Voting?
For authorized users — unique constraint (option_id, user_id) and check in controller. For anonymous voting — protection via IP + fingerprint. Fingerprint is generated on the frontend (library fingerprintjs) and passed in the header. This is 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();
Additionally, you can use Redis locks: Cache::lock('vote:'.$poll->id.':'.$userId, 10)->get() — this prevents simultaneous submission from one account.
How to Scale Voting to Thousands of Participants?
A PHP application with SSE keeps the connection open. 1,000 concurrent users = 1,000 PHP workers. This is expensive. Solution: offload broadcasting 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 a poll.updated event to Redis, Laravel Echo broadcasts 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 handles hundreds of thousands of connections on a single Node.js process. For monitoring, use Laravel Horizon: it shows the number of active SSE workers and response time. This saves up to 70% on server infrastructure costs compared to direct SSE workers.
Data Schema and Optimization
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)
);
Aggregation is computed via a materialized view or direct COUNT. Under peak load (live stream, 5,000+ participants), it is better to store counters separately and increment via Redis: HINCRBY poll:42:counts 1 1.
Client-Side and Sending Votes
const pollId = 42;
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.onmessage = (event) => {
const { counts } = JSON.parse(event.data);
updateBars(counts);
};
source.onerror = () => {
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 + '%';
});
}
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 have already voted');
}
}
Implementing an SSE Endpoint in 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',
]);
});
X-Accel-Buffering: no is a mandatory header when using Nginx as a proxy; otherwise data will accumulate in the buffer.
How to Test Real-Time Voting?
Use tools: Postman for sending votes, k6 for load testing, browser console for checking SSE connections. Main scenarios: 1) check receiving updates after voting; 2) simulate simultaneous votes from a thousand users; 3) check resilience on connection drop (SSE auto-reconnects).
Common Mistakes and Solutions
- N+1 queries: when retrieving the list of votes with lazy-loaded options. Use
with('options')in Eloquent. - Missing
X-Accel-Buffering: without this header, Nginx buffers the SSE stream, and users see data in chunks. - Ignoring
connection_aborted(): without it, PHP workers continue hanging, consuming memory. - Not using Redis for counters: direct COUNT from DB on each update creates load. Redis increments are much faster.
Development Timelines
| Stage | Time |
|---|---|
| Basic voting (SSE, authorized) | 2–3 days |
| Anonymous voting + anti-duplicate | +1 day |
| Multi-option polls + history | +1 day |
| Scaling via Pusher/Echo | +2 days |
| Administrative interface | 2–3 days |
We guarantee a bug-free period of 30 days after delivery, provide documentation, and train your team. To evaluate your project, contact us — get a detailed plan and precise timeline. Request a consultation to ensure your voting runs smoothly under any load.







