← Back to list

Building a Real-Time Voting System: Powered by MongoDB

When All People Vote at Once, This System Doesn’t Break

Idani-Ahmed · 2025-12-06 23:21 · 51 claps · 4.6 min read
#voting-system #server-sent-events #mongodb #expressjs #software-architecture
Open on Medium ↗
Wiki topics: 🌐 · Web Development 📰 · Journalism & News 🏛️ · Architecture 🏛️ · Politics

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:

  1. Admin clicks “Present Team X” → MongoDB update
  2. Change stream detects update
  3. Broadcast function iterates through clients Set
  4. 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 $inc operations 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