Building a Real-Time Voting System: Powered by MongoDB
When All People Vote at Once, This System Doesn’t Break
Building a Real-Time Voting System: Powered by MongoDB
When All People Vote at Once, This System Doesn’t Break
Context
For Hack for Good 4.0, a pitch competition at our university INSAT attended by over 500 people, I built a custom voting system that had to work reliably under heavy load. No Google Forms. No Mentimeter. Everything was designed from scratch.
The main technical challenges were:
- Counting votes accurately when many users vote simultaneously
- Broadcasting updates to hundreds of devices in real-time
- Preventing duplicate votes without complex locking
- Ensuring only one team to vote for at a time (“presenting” team)
Here’s some of the challenges I had to think about
Challenge 1: Real-Time Updates with Server-Sent Events
The requirement: When the admin switches teams, all 500+ screens must update instantly.
Most developers default to WebSockets. I used Server-Sent Events instead not out of preference, but because SSE was objectively better for this use case.
Why SSE Over WebSockets?
What we need:
- Server broadcasts “Team X is now presenting”
- Clients receive and display the update
- No bidirectional communication required
Votes go through regular POST requests. The SSE channel only broadcasts state changes.
SSE Implementation
// Store all connected clients
const clients = new Set();
// SSE endpoint
app.get("/api/sendvote", (req, res) => {
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Store this response stream
clients.add(res);
// Send connection confirmation
res.write(`data: ${JSON.stringify({ connected: true })}\n\n`);
// Cleanup on disconnect
req.on("close", () => {
clients.delete(res);
});
// Connection stays open indefinitely
});
Key insight: Each res object is a live HTTP response stream. By storing them in a Set, we can write to all of them whenever state changes.
Broadcasting to all clients:
function broadcast(data) {
const message = `data: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => {
try {
client.write(message);
} catch (err) {
clients.delete(client); // Remove dead connections
}
});
}
Connecting SSE to MongoDB Change Streams
MongoDB Change Streams watch the database for changes in real-time:
const changeStream = Team.watch([
{
$match: {
"updateDescription.updatedFields.presenting": { $exists: true }
}
}
], { fullDocument: "updateLookup" });
changeStream.on("change", (change) => {
if (change.fullDocument?.presenting === true) {
broadcast({
teamID: change.fullDocument._id,
teamName: change.fullDocument.teamName
});
}
});
The complete flow:
- Admin clicks “Present Team X” → MongoDB update
- Change stream detects update
- Broadcast function iterates through
clientsSet - All 500+ browsers receive update

Client-Side Implementation
const eventSource = new EventSource('/api/sendvote', {
withCredentials: true
});
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
setCurrentTeam(data.teamName);
setTeamID(data.teamID);
};
// Automatic reconnection
eventSource.onerror = () => {
console.log("Connection lost, retrying...");
};
Challenge 2: The Vote Counting Race Condition
The problem: What happens when +500 people vote for the same team at the exact same moment?
The Naive Approach (That Breaks)
// DON'T DO THIS
const team = await Team.findById(teamId);
team.nyes = team.nyes + 1; // Read current count, add 1
await team.save(); // Write new count
With concurrent requests:
Time Vote A Vote B Database
---- ------ ------ --------
T1 Read count = 50 count = 50
T2 Read count = 50 count = 50
T3 Calculate 50 + 1 count = 50
T4 Calculate 50 + 1 count = 50
T5 Write count = 51 count = 51
T6 Write count = 51 count = 51
Result: Two votes, count only increased by 1
Both threads read “50” before either finished writing. Vote lost.
The Solution: MongoDB’s $inc Operator
MongoDB’s $inc operator executes the increment at the database level:
await Team.updateOne(
{ _id: teamId },
{ $inc: { nyes: 1 } } // ← Atomic at database level
);
What happens internally:
When concurrent votes arrive:
- Both
$incoperations reach MongoDB simultaneously - The document-level lock of WiredTiger (MongoDB’s storage engine) document-level lock ensures they execute serially
- Each increment is applied atomically
- Count goes from 50 → 51 → 52 correctly”
The database handles the race condition internally. No transactions needed. No application-level locks. The increments may execute concurrently — WiredTiger ensures they don’t overwrite each other.
Challenge 3: Preventing Duplicate Votes
The problem: Ensuring each voter can only vote once per team.
The Race Condition
Time Request A Request B
---- --------- ---------
T1 Check: Has voted? No
T2 Check: Has voted? No
T3 Record vote
T4 Record vote
Result: Duplicate vote recorded
The Solution: Atomic Check-and-Set
MongoDB’s findOneAndUpdate performs check and update atomically:
const voter = await Voter.findOneAndUpdate(
{
code: voterCode,
[`votes.${teamId}`]: { $exists: false } // Only match if vote doesn't exist
},
{
$set: {
[`votes.${teamId}`]: {
vote: "yes",
votedAt: new Date()
}
}
}
);
if (!voter) {
return res.status(400).json({ message: "Already voted" });
}
// Vote recorded, increment team count
await Team.updateOne({ _id: teamId }, { $inc: { nyes: 1 } });
Challenge 4: Only One Team Presenting at a Time
The problem: What if two admins accidentally present different teams simultaneously?
Result: Half the audience sees Team A, half sees Team B. Votes go to wrong teams. Chaos.
The Solution: Partial Unique Index
teamSchema.index(
{ presenting: 1 },
{
unique: true,
partialFilterExpression: { presenting: true }
}
);
What this enforces:
- Only ONE document can have
presenting: true - Unlimited documents can have
presenting: false(index doesn't apply) - Any attempt to set a second team to
presenting: true→ rejected at database level
The enforcement happens in the storage engine. Not in application code. Not in middleware. In WiredTiger itself.
Why this is reliable:
- No application code can bypass it
- Works across all database clients
- Enforced even via direct database access
- Fast lookup (B-tree index, essentially instant)
Coordinating State Transitions
While the unique index prevents races, we use transactions for coordinated state changes:
async function changePresentingTeam(newTeamId) {
const session = await mongoose.startSession();
session.startTransaction();
try {
// Mark current team as done
await Team.updateOne(
{ presenting: true },
{
$set: {
presenting: false,
presented: true,
presentedAt: new Date()
}
},
{ session }
);
// Set new team as presenting
await Team.updateOne(
{ _id: newTeamId },
{ $set: { presenting: true } },
{ session }
);
await session.commitTransaction();
} catch (err) {
await session.abortTransaction();
throw err;
}
}
When to use transactions:
- Multiple related updates that must succeed together
- State transitions requiring coordination
- When partial updates would leave invalid state
Key decisions:
SSE over WebSockets — Simpler, native support, automatic reconnection MongoDB Change Streams — Real-time watching instead of polling Atomic operations over transactions — 3x faster, simpler code Partial unique indexes — Database-enforced constraints In-memory client Set — No message broker needed for 600 connections
Nginx configuration:
location /api/sendvote {
proxy_pass http://backend:3000;
proxy_buffering off; # Critical for SSE
proxy_cache off;
proxy_read_timeout 24h;
proxy_http_version 1.1;
proxy_set_header Connection '';
}
MongoDB requirements:
- Replica set (required for change streams)
Conclusion
The system handled 1,700+ votes across 5 teams with zero lost votes, zero duplicates, and sub-200ms end-to-end latency.
Let’s Connect
Building real-time systems? Dealing with concurrency challenges? Want to discuss database internals? LinkedIn: Ahmed Idani
Questions about the architecture? Want to collaborate? Found an edge case? Drop me a message.
메타데이터
- post_id
- bf83ff07013a
- slug
- building-a-real-time-voting-system-bf83ff07013a
- url
- https://medium.com/@idaniahmed72/building-a-real-time-voting-system-bf83ff07013a
- canonical_url
- https://medium.com/@idaniahmed72/building-a-real-time-voting-system-bf83ff07013a
- author_url
- https://medium.com/@idaniahmed72
- status
- ok
- fetched_at
- 2026-07-15 20:35:34