How to Build a Live Voting and Polling System

Build a real-time voting system where results update live as votes come in — no refresh needed. Perfect for Q&A sessions, live events, and audience interaction.

Live polls turn passive audiences into active participants. Whether you are running a conference Q&A, a webinar with hundreds of attendees, or gathering in-app product feedback, showing results update in real time keeps people engaged in a way that a static form never can.

In this guide you will build a live voting system backed by Apinator WebSockets. Votes are submitted via a plain HTTP POST, and the updated results are immediately pushed to every connected viewer — no polling, no refresh button.

Use Cases

Before diving into code, here are a few scenarios where live polls genuinely change the experience:

  • Conference Q&A: Attendees upvote questions. The moderator sees the ranking shift in real time and calls on the most popular ones first.
  • Live webinar polls: "Which challenge is most painful for your team?" Options animate as hundreds of viewers click simultaneously.
  • Product feedback: Embedded in a release announcement, readers vote on which feature they want next while still on the page.
  • In-app surveys: NPS or satisfaction scores update on the admin dashboard the instant a user submits.

The common thread: a result that is only visible after everyone has voted is far less compelling than one that moves while votes are still coming in.

Architecture Overview

The system has three moving parts:

  1. HTTP POST endpoint — accepts a vote, validates it, increments the count, and publishes the new totals.
  2. Apinator channel — a public channel named poll-{id} that every viewer subscribes to. When the server publishes a poll.updated event, all connected clients receive it instantly.
  3. Client rendering — a lightweight JS snippet that listens for poll.updated and animates the bar chart without touching the server again.

Keeping the vote submission as a regular HTTP request (rather than a WebSocket message) makes it easy to apply rate limiting, authenticate the request with a session cookie, and store the result in your existing database — all before broadcasting.

Server Implementation

Install the server SDK:

npm install @apinator/server

Below is a minimal Express server. Vote counts are stored in memory here; swap in Redis or Postgres for production.

import express from "express";
import { RealtimeServer } from "@apinator/server";

const app = express();
app.use(express.json());

const realtime = new RealtimeServer({
  appId: process.env.APINATOR_APP_ID,
  key: process.env.APINATOR_KEY,
  secret: process.env.APINATOR_SECRET,
});

// In-memory store: { [pollId]: { [optionId]: count, voters: Set<string> } }
const polls = {};

function getPoll(pollId) {
  if (!polls[pollId]) {
    polls[pollId] = { options: {}, voters: new Set() };
  }
  return polls[pollId];
}

app.post("/polls/:pollId/vote", async (req, res) => {
  const { pollId } = req.params;
  const { optionId, voterId } = req.body;

  if (!optionId || !voterId) {
    return res.status(400).json({ error: "optionId and voterId are required" });
  }

  const poll = getPoll(pollId);

  // Prevent duplicate votes from the same user
  if (poll.voters.has(voterId)) {
    return res.status(409).json({ error: "Already voted" });
  }

  poll.voters.add(voterId);
  poll.options[optionId] = (poll.options[optionId] ?? 0) + 1;

  const totalVotes = Object.values(poll.options).reduce((a, b) => a + b, 0);

  // Build the payload clients will receive
  const results = Object.entries(poll.options).map(([id, count]) => ({
    id,
    count,
    percentage: Math.round((count / totalVotes) * 100),
  }));

  // Publish to the public poll channel
  await realtime.trigger(`poll-${pollId}`, "poll.updated", {
    pollId,
    totalVotes,
    results,
  });

  res.json({ success: true, totalVotes, results });
});

app.listen(3000, () => console.log("Server running on :3000"));

The trigger call is the only Apinator-specific line. Everything else is ordinary Express. After this call returns, every browser subscribed to poll-{pollId} has already received the update.

Client Implementation

Include the Apinator client SDK from a CDN or bundle it with your build tool:

<script src="https://cdn.apinator.io/sdk/latest/apinator.min.js"></script>

Here is a self-contained poll widget in vanilla JS:

<div id="poll-widget">
  <h2 id="poll-question">Which feature do you want next?</h2>
  <div id="poll-options"></div>
  <p id="vote-count">0 votes</p>
</div>

<script>
  const POLL_ID = "product-q1-2026";
  const VOTER_ID = crypto.randomUUID(); // or pull from session/auth

  const client = new Apinator.RealtimeClient({
    key: "YOUR_PUBLIC_KEY",
    authEndpoint: "/apinator/auth",
  });

  const channel = client.subscribe(`poll-${POLL_ID}`);

  channel.bind("poll.updated", (data) => {
    renderResults(data.results, data.totalVotes);
  });

  function renderResults(results, totalVotes) {
    const container = document.getElementById("poll-options");
    document.getElementById("vote-count").textContent =
      `${totalVotes} vote${totalVotes !== 1 ? "s" : ""}`;

    container.innerHTML = results
      .map(
        (opt) => `
        <div class="poll-option" data-id="${opt.id}">
          <span class="option-label">${opt.id}</span>
          <div class="bar-track">
            <div
              class="bar-fill"
              style="width: ${opt.percentage}%; transition: width 0.4s ease;"
            ></div>
          </div>
          <span class="option-pct">${opt.percentage}%</span>
        </div>
      `
      )
      .join("");
  }

  async function submitVote(optionId) {
    await fetch(`/polls/${POLL_ID}/vote`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ optionId, voterId: VOTER_ID }),
    });
  }

  // Wire up vote buttons
  document.querySelectorAll("[data-vote]").forEach((btn) => {
    btn.addEventListener("click", () => submitVote(btn.dataset.vote));
  });
</script>

The width transition on .bar-fill gives you the animated bar chart for free. When a new poll.updated event arrives, the bars smoothly expand or contract to reflect the new percentages.

Preventing Duplicate Votes

On the server, poll.voters is a Set of voter IDs. Before incrementing any count, the handler checks whether the ID is already in the set and returns a 409 if so.

In production, store this set in Redis (SADD poll:{id}:voters {voterId}, check with SISMEMBER) so it survives restarts and scales across multiple server instances:

// Redis-based duplicate check
const alreadyVoted = await redis.sismember(`poll:${pollId}:voters`, voterId);
if (alreadyVoted) {
  return res.status(409).json({ error: "Already voted" });
}
await redis.sadd(`poll:${pollId}:voters`, voterId);
await redis.hincrby(`poll:${pollId}:options`, optionId, 1);

For anonymous polls where you cannot rely on authentication, derive the voterId from the session cookie or a fingerprint stored in localStorage. This is not fraud-proof, but it is a reasonable barrier for most use cases.

Showing Who Is Watching with a Presence Channel

Want to display "47 people watching this poll"? Subscribe to a presence channel alongside the regular poll channel. Presence channels track connected members automatically.

const presenceChannel = client.subscribe(`presence-poll-${POLL_ID}`, {
  auth: {
    // The auth endpoint returns a signed token that includes user info
    endpoint: "/apinator/auth",
    params: { user_id: VOTER_ID, display_name: "Anonymous" },
  },
});

presenceChannel.bind("apinator:subscription_succeeded", (members) => {
  updateViewerCount(members.count);
});

presenceChannel.bind("apinator:member_added", () => {
  updateViewerCount(presenceChannel.members.count);
});

presenceChannel.bind("apinator:member_removed", () => {
  updateViewerCount(presenceChannel.members.count);
});

function updateViewerCount(count) {
  document.getElementById("viewer-count").textContent =
    `${count} watching live`;
}

The server-side auth endpoint signs the presence token using the @apinator/server SDK:

app.post("/apinator/auth", (req, res) => {
  const { socket_id, channel_name, user_id, display_name } = req.body;

  const auth = realtime.authenticateChannel(socket_id, channel_name, {
    user_id,
    user_info: { display_name },
  });

  res.json(auth);
});

Now the viewer count updates the moment someone opens or closes the poll page.

Putting It Together

The full flow in one sentence: a user clicks an option, your server increments a counter and calls realtime.trigger, and every other browser on the planet with that channel open sees the bars move within milliseconds.

From here you can extend the system in many directions — add a countdown timer that closes the poll, emit a poll.closed event that locks the UI, or stream results into an admin dashboard alongside presence data to see engagement in real time. The WebSocket layer stays the same; only your business logic changes.