Preventing Duplicate Votes in a Polling API Under Concurrent Requests
TIL that a 'check if the user already voted, then write' guard is racy under concurrent requests — the fix was a unique compound index plus an atomic $inc, so MongoDB rejects the duplicate instead of the app trying to catch it first.
The Problem #
Pollarise's vote count for a single option would occasionally be one higher than the number of distinct voters who could have cast it — traceable to the same person's vote landing twice.
Context #
Voting is a single tap in the UI, but on a flaky mobile connection the client retries a request that timed out without knowing whether the first one actually reached the server. Two vote requests for the same poll and voter can arrive within milliseconds of each other, sometimes handled by two different Node processes behind the load balancer.
What I Tried #
The original vote handler checked for an existing vote before writing a new one:
const existing = await Vote.findOne({ poll: pollId, voter: voterId });
if (existing) throw new AlreadyVotedError();
await Vote.create({ poll: pollId, voter: voterId, option: optionId });
await Poll.updateOne(
{ _id: pollId, "options._id": optionId },
{ $inc: { "options.$.tally": 1 } },
);What Went Wrong #
findOne and create are two separate round trips, not one atomic operation. When both retried requests ran that check within the same few milliseconds, neither one saw the other's write yet — both found no existing vote, both passed the guard, and both inserted a vote. The tally ended up incremented twice for one person.
The Solution #
Moved the uniqueness guarantee out of application logic and into a database constraint, and let MongoDB's own duplicate-key rejection double as the "already voted" check:
// schema
voteSchema.index({ poll: 1, voter: 1 }, { unique: true });
async function castVote(pollId: string, voterId: string, optionId: string) {
try {
await Vote.create({ poll: pollId, voter: voterId, option: optionId });
} catch (err) {
if (isDuplicateKeyError(err)) {
throw new AlreadyVotedError();
}
throw err;
}
// $inc is atomic per document — no read-modify-write race on the tally either
await Poll.updateOne(
{ _id: pollId, "options._id": optionId },
{ $inc: { "options.$.tally": 1 } },
);
}
function isDuplicateKeyError(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: number }).code === 11000;
}Two concurrent inserts for the same (poll, voter) pair now can't both succeed. The database serializes them: one insert wins, the other fails with error code 11000, which the handler treats as "you already voted" instead of a server error.
Why It Works #
A unique index is enforced by the storage engine on every write, not by a query the application runs and hopes stays valid until its next query. There's no window between "check" and "write" for a second request to sneak through, because there is no separate check — the write itself is the check. The tally increment gets the same property from $inc, which MongoDB applies atomically to a single document, so two concurrent increments can't read the same starting value and both write +1 from it.
Lessons Learned #
"Check, then write" is a race condition waiting for enough concurrency to trigger it, and mobile clients on unreliable networks manufacture that concurrency for free through retries. Anything that mutates shared state on behalf of a specific actor (one vote per user, one signup per email) should rely on a constraint the database enforces atomically, not a query the application runs first and trusts.
What I Would Do Differently #
I'd add the unique index and build the vote handler around catching its rejection from the start, instead of shipping the intuitive-looking check-then-write version and only discovering the race once duplicate votes showed up in production data.
Related Concepts #
Unique indexes vs. application-level uniqueness checks, atomic update operators ($inc, findOneAndUpdate), idempotent request handling, optimistic concurrency control.
Related content
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.
Debugging a MongoDB/WiredTiger Cache Pressure Issue in Production
TIL that intermittent MongoDB slowdowns that don't show up in query logs can be a WiredTiger cache eviction problem, not a query problem — and the fix was a resource limit, not an index.
Designing MongoDB Schemas with Mongoose Without Fighting Yourself Later
TIL that a MongoDB collection modeled around how a screen displayed data instead of how the data actually related caused a rewrite once a second screen needed the same data differently.