</> MAANG.io
system design · 301

Advanced

Master system design interviews with scalable architecture patterns, distributed systems, and real-world design challenges.

0/480 solved 0% complete

Section 8: One Person, One Vote - Deep Dive: Votes, Scores and Ranking

Executive Summary: Votes are 86% of all writes and carry the design's one strict invariant: at most one vote per person per post. The vote row is the truth and the score is a cache of it. One transaction on the question's shard reads the existing vote, writes the new one, adjusts the score and appends an outbox event, and the primary key settles every race. Reputation, hot ranking and search boosts follow asynchronously from that event. Ranking then uses three different formulas for three different jobs: score for answers, the Wilson lower bound for comments, and a non-decaying hot score for feeds.

Section 7 ended on the numbers the page deliberately doesn't cache. The interviewer picks it up.

Interviewer: "Ana upvotes Daniel's answer. Walk me through it, and tell me what can go wrong."

You: "Three things must hold. Ana has at most one vote on that answer, ever. The score equals the sum of the vote rows. And Ana sees their own vote immediately, even though other readers see the cached count for up to about two minutes. The first two are invariants, so they live in one transaction. The third is a display rule, so it lives in the response."

The Vote Transaction

The API from Section 4 is set-state: PUT /api/v1/posts/{postId}/vote {value: 1 | -1 | 0}. The server never adds; it compares the new value with the stored one and applies the difference. Section 5 put questions, answers, comments and their votes on one shard, so this is a local transaction. Comment IDs come from the same generator, so comment votes use the same votes table and the same code path; only the counter row differs.

async function setVote(voter: User, postId: bigint, value: 1 | -1 | 0) {
  const post = await loadPostOrComment(postId);              // routes by the ID's shard bits
  if (post.authorId === voter.id) throw http(403, "no self-votes");
  if (value === -1 && voter.reputation < 125) throw http(403, "downvote needs 125 rep");

  return shardOf(postId).tx(async (db) => {
    const row = await db.oneOrNone(`SELECT value FROM votes
        WHERE post_id = $1 AND user_id = $2 FOR UPDATE`, [postId, voter.id]);
    const old = row?.value ?? 0;
    if (old === value) return db.currentScore(postId, value); // retry or double click: delta 0

    if (value === 0)  await db.exec(`DELETE FROM votes WHERE post_id = $1 AND user_id = $2`, [postId, voter.id]);
    else if (old === 0) await db.exec(`INSERT INTO votes (question_id, post_id, user_id, value, created_at)
        VALUES ($1, $2, $3, $4, now())`, [post.questionId, postId, voter.id, value]); // PK guards the race
    else await db.exec(`UPDATE votes SET value = $3 WHERE post_id = $1 AND user_id = $2`, [postId, voter.id, value]);

    const up = (value === 1 ? 1 : 0) - (old === 1 ? 1 : 0);
    const down = (value === -1 ? 1 : 0) - (old === -1 ? 1 : 0);
    // posts.score/up/down or comments.ups/downs; a question or answer vote also bumps its question's doc_version (Section 9)
    const { score, docVersion } = await db.bumpCounters(post, up - down, up, down);
    await db.exec(`INSERT INTO outbox (aggregate_id, event_type, payload) VALUES ($1, 'VoteChanged', $2)`,
      [post.questionId, { postId, authorId: post.authorId, voterId: voter.id, old, value, docVersion }]);
    return { score, myVote: value };
  }); // tx() commits, acked by the sync standby (RPO 0); a unique violation retries once
}

A switch from up to down is a delta of −2: the old +1 goes away and the new −1 arrives. The voter's reputation comes from their session, refreshed from the users DB; a few seconds of staleness on the 125 threshold harms nobody.

Interviewer: "Priya has the answer open in two tabs and clicks upvote in both at the same instant. FOR UPDATE locks nothing when there's no row yet."

You: "Right, both transactions read 'no vote' and both try to INSERT. The primary key (post_id, user_id) lets exactly one commit. The other gets a unique violation, and the service retries the whole transaction once. This time SELECT … FOR UPDATE finds the row with value 1, the delta is 0, and it returns the same score. The database enforces the invariant, not my code's timing."

Sequence: an upvote on Daniel's answer runs one shard transaction that reads the existing vote FOR UPDATE, inserts the vote row, adds 1 to the score and writes an outbox row, then CDC publishes VoteChanged to Kafka for reputation, hot rank and search boost

The same transitions as a state machine, one per (voter, post) pair:

Vote state machine: No vote, Upvoted and Downvoted, with the value sent and the score change for each transition; downvoting needs 125 rep

Every arrow that repeats the current state is a no-op, which is why a retried PUT can never double count.

Quick check before moving on:

Sign in to continue reading

The rest of this lesson is available with a free account. Signing in with Google or Microsoft is free.

Sign in to read the full lesson

Sign in to MAANG.io

Use your Google or Microsoft account — no password to remember.

Continue with Google Continue with Microsoft

Please accept the terms above to continue.