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:
- HTTP POST endpoint — accepts a vote, validates it, increments the count, and publishes the new totals.
- Apinator channel — a public channel named
poll-{id}that every viewer subscribes to. When the server publishes apoll.updatedevent, all connected clients receive it instantly. - Client rendering — a lightweight JS snippet that listens for
poll.updatedand 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.