Real-Time Voting/Polls on Website

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

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