Build a Real-Time Polling App with WebSockets (Step by Step)
In this tutorial, you'll learn about Build a Real. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a real-time polling application using Express, Socket.IO, and Chart.js where multiple users can vote simultaneously and see live chart updates without refreshing the page.
What You'll Build
You'll build a web-based polling app where anyone can create a poll with multiple options, share a link, and watch votes accumulate in real time as a bar chart. When a user votes, the chart animates instantly on every connected client — no page reload needed.
Why Real-Time Polling Matters
Real-time updates create a sense of liveness and engagement. Think of audience Q&A at a conference, live quiz results in a classroom, or instant feedback on a product feature vote. Users stay on the page longer and feel part of something happening right now. The same WebSocket technology powers live file status updates in DodaZIP when compressing large archives.
Prerequisites
- Node.js 18+ installed
- Basic JavaScript knowledge for both frontend and backend
- Familiarity with Express.js or any Node.js framework
Step 1: Project Setup
mkdir polling-app
cd polling-app
npm init -y
npm install express socket.io uuid
mkdir public
Create this project structure:
polling-app/
├── server.js # Express + Socket.IO backend
├── public/
│ ├── index.html # Poll list view
│ └── poll.html # Individual poll view with chart
└── polls.json # In-memory storage (simple)
Step 2: The Server
The server manages polls, tracks votes, and broadcasts updates to all connected clients via WebSocket.
// server.js
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const { v4: uuidv4 } = require("uuid");
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static("public"));
app.use(express.json());
const polls = {};
app.post("/api/polls", (req, res) => {
const { question, options } = req.body;
const id = uuidv4();
polls[id] = {
id,
question,
options: options.map((opt) => ({ text: opt, votes: 0 })),
total: 0,
};
res.json({ id });
});
app.get("/api/polls/:id", (req, res) => {
res.json(polls[req.params.id] || null);
});
io.on("connection", (socket) => {
socket.on("vote", ({ pollId, optionIndex }) => {
const poll = polls[pollId];
if (!poll) return;
poll.options[optionIndex].votes++;
poll.total++;
io.emit("update", poll);
});
});
server.listen(3000, () => console.log("Server on http://localhost:3000"));
Expected output: Server starts on port 3000. Opening http://localhost:3000 serves the static files.
Step 3: The Frontend — Create Poll
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Live Polling App</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
input, button { font-size: 1rem; padding: 8px 12px; margin: 4px 0; }
.option-row { display: flex; gap: 8px; align-items: center; }
.option-row input { flex: 1; }
button { background: #6366f1; color: white; border: none; border-radius: 6px; cursor: pointer; }
button:hover { background: #4f46e5; }
</style>
</head>
<body>
<h1>Create a Poll</h1>
<input id="question" placeholder="Your question..." style="width: 100%;">
<div id="options">
<div class="option-row"><input placeholder="Option 1"></div>
<div class="option-row"><input placeholder="Option 2"></div>
</div>
<button onclick="addOption()">+ Add Option</button>
<button onclick="createPoll()" style="margin-left: 8px;">Create Poll</button>
<script src="/socket.io/socket.io.js"></script>
<script>
function addOption() {
const div = document.createElement("div");
div.className = "option-row";
div.innerHTML = '<input placeholder="Option ' + (document.querySelectorAll(".option-row").length + 1) + '">';
document.getElementById("options").appendChild(div);
}
async function createPoll() {
const question = document.getElementById("question").value;
const inputs = document.querySelectorAll(".option-row input");
const options = Array.from(inputs).map((i) => i.value).filter(Boolean);
const res = await fetch("/api/polls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question, options }),
});
const { id } = await res.json();
window.location.href = "/poll.html?id=" + id;
}
</script>
</body>
</html>
Step 4: The Frontend — Live Poll View
<!-- public/poll.html -->
<!DOCTYPE html>
<html>
<head>
<title>Live Poll</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; }
.option { margin: 8px 0; }
.option button { width: 100%; text-align: left; padding: 12px 16px; font-size: 1rem;
border: 2px solid #e2e8f0; border-radius: 8px; background: white; cursor: pointer; }
.option button:hover { border-color: #6366f1; background: #f5f3ff; }
#total { text-align: center; color: #64748b; margin-top: 16px; }
</style>
</head>
<body>
<h1 id="question">Loading...</h1>
<div id="options"></div>
<p id="total"></p>
<canvas id="chart" height="200"></canvas>
<script src="/socket.io/socket.io.js"></script>
<script>
const params = new URLSearchParams(location.search);
const pollId = params.get("id");
const socket = io();
let chart = null;
async function loadPoll() {
const res = await fetch("/api/polls/" + pollId);
if (!res.ok) return (document.body.innerHTML = "<h1>Poll not found</h1>");
renderPoll(await res.json());
}
function renderPoll(poll) {
if (!poll) return;
document.getElementById("question").textContent = poll.question;
document.getElementById("total").textContent = poll.total + " total votes";
const container = document.getElementById("options");
container.innerHTML = "";
poll.options.forEach((opt, i) => {
const div = document.createElement("div");
div.className = "option";
div.innerHTML = `<button onclick="vote(${i})">${opt.text} (${opt.votes})</button>`;
container.appendChild(div);
});
if (chart) chart.destroy();
chart = new Chart(document.getElementById("chart"), {
type: "bar",
data: {
labels: poll.options.map((o) => o.text),
datasets: [{ label: "Votes", data: poll.options.map((o) => o.votes),
backgroundColor: "#6366f1" }],
},
options: { responsive: true, animation: { duration: 300 },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } },
});
}
function vote(index) {
socket.emit("vote", { pollId, optionIndex: index });
}
socket.on("update", (poll) => {
if (poll.id === pollId) renderPoll(poll);
});
loadPoll();
</script>
</body>
</html>
Expected output: Create a poll, copy the URL to another browser tab, vote in one tab — the chart animates and vote counts update instantly in the other tab.
Architecture
sequenceDiagram
participant A as User A
participant B as User B
participant S as Socket.IO Server
participant P as Poll Store
A->>P: POST /api/polls (create)
P-->>A: Return poll ID
A->>S: connect + vote(pollId, 0)
S->>P: Increment vote
S->>A: update(poll)
S->>B: update(poll)
B->>S: vote(pollId, 1)
S->>P: Increment vote
S->>A: update(poll)
S->>B: update(poll)
Common Errors
1. Socket.IO connection fails with 404
The client-side script must load from /socket.io/socket.io.js served by the server. Make sure server.js creates an http.Server and passes it to new Server(server) — Express alone doesn't handle WebSocket upgrades.
2. Votes not syncing across clients
Check that io.emit("update", poll) broadcasts to all connected sockets, not just the sender. Using socket.emit sends only to that one client; io.emit sends to everyone.
3. Chart.js not rendering
The canvas element must exist before the chart script runs. Place the <script> tag after the <canvas> in the HTML, or wrap in DOMContentLoaded.
4. Poll data lost on server restart
This demo stores polls in memory. For persistence, add SQLite or Redis. The polls object resets every time the server restarts.
5. CORS errors in production
When serving frontend from a different domain than the server, configure CORS: new Server(server, { cors: { origin: "https://yourdomain.com" } }).
Practice Questions
1. How does Socket.IO differ from raw WebSockets? Socket.IO adds automatic reconnection, fallback to HTTP long-polling, rooms, namespaces, and a simpler event-based API on top of WebSocket.
2. Why does the chart animate without a page refresh?
Socket.IO pushes the updated poll data to all connected clients via the update event. The client's socket.on("update", ...) handler calls renderPoll() which updates the Chart.js instance.
3. How could you prevent a user from voting multiple times?
Track voted poll IDs in localStorage on the client, or assign a session cookie on the first visit and check it server-side before applying the vote.
4. Challenge: Add poll expiry
Add an expiresAt field to each poll. On the server, check expiry before applying votes. On the client, disable voting buttons and show "Poll closed" when expired.
5. Challenge: Live percentage display
Modify the option rendering to show percentages alongside raw vote counts (67%), updated in real time as votes come in.
FAQ
Next Steps
- Add vote persistence with SQLite so data survives restarts
- Explore WebSocket security — Rate Limiting and origin validation
- Try building the Real-Time Chat App project for another WebSocket use case
- Learn about scaling with Redis pub/sub in the Redis tutorial
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro