Low Level Design: Voting and Reaction System

Problem Statement

Design a voting and reaction system that allows users to upvote, downvote, or attach emoji reactions to content items such as posts, comments, or answers. The system must handle high concurrency, prevent duplicate votes, support real-time vote count updates, and include anti-fraud throttling.

Clarifying Questions

  • Do we support multiple reaction types (like, love, laugh) or just binary up/down votes?
  • Can a user change or retract their vote?
  • Do vote counts need to be strongly consistent or is eventual consistency acceptable?
  • What is the expected read-to-write ratio? (Typically reads far outnumber writes.)
  • Should vote counts be visible in real time (WebSocket/SSE) or on page refresh?
  • Do we need per-user vote history for display (e.g., highlight the button the user already clicked)?

Core Requirements

Functional

  • Cast, change, or retract a vote or reaction on any content item.
  • Retrieve the current vote tally and the requesting user's vote state for a given item.
  • Support a configurable set of reaction types (thumbs-up, heart, laugh, etc.).
  • Broadcast count updates in near-real-time to subscribed clients.

Non-Functional

  • Idempotent vote operations: clicking the same vote twice toggles it off, never double-counts.
  • Throughput: support tens of thousands of vote writes per second at peak.
  • Read latency: vote counts served in < 10 ms from cache.
  • Anti-fraud: rate-limit vote casting per user and detect coordinated manipulation.

Data Model

votes table (MySQL / PostgreSQL)

CREATE TABLE votes (
  user_id     BIGINT       NOT NULL,
  item_id     BIGINT       NOT NULL,
  item_type   VARCHAR(32)  NOT NULL,   -- post, comment, answer
  vote_value  SMALLINT     NOT NULL,   -- +1, -1, or 0 (retracted)
  reaction    VARCHAR(32)  DEFAULT NULL,
  created_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (user_id, item_id, item_type)
);

The composite primary key (user_id, item_id, item_type) enforces one row per user-item pair, making duplicate-vote prevention a database-level guarantee rather than application logic.

vote_counts table (denormalized cache)

CREATE TABLE vote_counts (
  item_id     BIGINT       NOT NULL,
  item_type   VARCHAR(32)  NOT NULL,
  upvotes     BIGINT       NOT NULL DEFAULT 0,
  downvotes   BIGINT       NOT NULL DEFAULT 0,
  PRIMARY KEY (item_id, item_type)
);

reaction_registry table

CREATE TABLE reaction_types (
  code        VARCHAR(32)  PRIMARY KEY,
  label       VARCHAR(64)  NOT NULL,
  icon_url    TEXT,
  is_active   TINYINT(1)   NOT NULL DEFAULT 1
);

Storing reaction types in a table rather than an enum allows product teams to add or retire reactions without schema migrations.

API Design

POST   /v1/votes
Body: { item_id, item_type, vote_value, reaction? }
Returns: { item_id, new_score, user_vote }

GET    /v1/votes/{item_type}/{item_id}
Returns: { upvotes, downvotes, score, user_vote, reactions: { [code]: count } }

GET    /v1/reactions
Returns: [ { code, label, icon_url } ]

Idempotent Vote Toggling

The core write operation uses an upsert with delta arithmetic to avoid race conditions:

-- Upsert the user vote row
INSERT INTO votes (user_id, item_id, item_type, vote_value)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
  vote_value = VALUES(vote_value),
  updated_at = NOW();

-- Recompute delta from old and new values, then adjust the counter
UPDATE vote_counts
SET upvotes   = upvotes   + GREATEST(0,  new_val) - GREATEST(0,  old_val),
    downvotes = downvotes + GREATEST(0, -new_val) - GREATEST(0, -old_val)
WHERE item_id = ? AND item_type = ?;

Both statements execute inside a single transaction. Because the primary key prevents two rows for the same user-item pair, re-submitting the same vote is a no-op at the database level.

Real-Time Vote Count Updates

After a successful write the application layer publishes a lightweight event to a Pub/Sub channel (e.g., Redis PUBLISH votes:{item_type}:{item_id} {score}). A WebSocket gateway subscribes to these channels and fans out the new count to all connected clients viewing that item. For clients that cannot maintain a persistent connection, a short-poll endpoint returning ETags keeps bandwidth minimal.

Because real-time delivery is best-effort, the authoritative count always comes from the database (or its cache). Clients reconcile on reconnect.

Caching Strategy

  • Vote counts: cached in Redis with key vc:{item_type}:{item_id}. TTL 60 seconds; invalidated on every write.
  • User vote state: cached per user per item with key uv:{user_id}:{item_type}:{item_id}. TTL 5 minutes.
  • Reaction registry: cached globally for 1 hour; changes rarely.
  • On a cache miss the application reads from a read replica, not the primary, to isolate read load.

Anti-Fraud Throttling

Rate Limiting

A sliding-window counter in Redis enforces a per-user vote budget:

Key:   rl:vote:{user_id}
Type:  Redis sorted set (score = timestamp, member = vote UUID)
Window: 60 seconds, limit: 60 votes

Before processing a vote the service calls ZREMRANGEBYSCORE to drop expired entries, then ZCARD to check the current count. If the limit is exceeded the request is rejected with HTTP 429.

Coordinated Manipulation Detection

  • A background job aggregates votes per item over 5-minute windows. Items receiving more than 3 standard deviations above the rolling mean are flagged for manual review.
  • Votes from accounts created within the last 24 hours are weighted at 0.1 in score calculations until the account ages.
  • IP-level clustering: more than 10 unique user votes from the same /24 subnet within 60 seconds triggers a CAPTCHA challenge.

Scalability Considerations

  • Sharding: partition the votes table by item_id % N so that hot items are distributed across shards.
  • Write batching: for extremely viral items, buffer writes in a Kafka topic and apply them in micro-batches to vote_counts, trading a few seconds of staleness for dramatically lower write pressure on the database.
  • Counter drift repair: a nightly job recomputes all vote_counts from the raw votes table to correct any drift introduced by failed transactions.

Edge Cases

  • Concurrent same-direction votes: handled by the upsert; only one row exists per user-item pair.
  • Vote on deleted item: a foreign-key constraint or a pre-write existence check prevents orphaned vote rows.
  • Reaction type retired mid-flight: the API validates reaction codes against the registry before inserting; retired codes return 400.
  • Network partition during write: idempotency keys on the POST endpoint allow clients to safely retry without double-counting.

Summary

A robust voting system anchors idempotency in the database schema through a composite primary key, uses delta-based counter updates inside transactions, pushes real-time counts through Redis Pub/Sub, and layers rate limiting and statistical anomaly detection to suppress fraud. Caching vote counts at the read path keeps p99 read latency under 10 ms even at millions of items.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is a voting and reaction system and how is idempotent vote toggling implemented?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A voting and reaction system lets users express sentiment on content through upvotes, downvotes, or emoji reactions, and aggregates those signals into counts that influence ranking and display. Idempotent vote toggling means that a user casting the same vote twice results in no net change: the second action undoes the first. This is implemented by storing a (user_id, target_id, vote_type) record per user. When a vote arrives the service performs an upsert: if no prior record exists it inserts and increments the count; if the same vote_type already exists it deletes the record and decrements the count (toggle off); if a different vote_type exists it updates the record and adjusts both the old and new type counts atomically. Using a unique constraint on (user_id, target_id) at the database level prevents duplicate votes even under concurrent requests.”
}
},
{
“@type”: “Question”,
“name”: “How are vote counts maintained accurately under high concurrency?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Vote counts are maintained with atomic increment and decrement operations rather than read-modify-write cycles, which would create race conditions. In a relational store this means using UPDATE content SET vote_count = vote_count + 1 WHERE id = X rather than fetching the count first. For very high-traffic content, counts are maintained in Redis using INCR and DECR commands, which are atomic single-threaded operations that never produce incorrect results under concurrency. The Redis counter is periodically flushed to the persistent store. An alternative is to avoid a single counter entirely and instead compute the count as a materialized aggregate over the vote records, refreshed asynchronously and cached. Sharding the vote record table by target_id distributes write load for popular content across multiple database nodes.”
}
},
{
“@type”: “Question”,
“name”: “How does anti-fraud throttling work in a voting system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Anti-fraud throttling limits how many votes a single user or IP address can cast within a time window to prevent vote stuffing. A sliding window rate limiter implemented in Redis tracks vote events per user_id and per IP with a TTL-based counter: each vote increments the counter and if the counter exceeds the threshold the vote is rejected with a 429 response. Additional signals include account age (new accounts get tighter limits), device fingerprint consistency, and velocity anomalies such as voting on hundreds of items within seconds. Suspicious vote bursts are soft-rejected (the user sees success but the votes are held in a quarantine queue) to avoid revealing detection logic. A background scoring job reviews quarantined votes and either commits or discards them based on further signals.”
}
},
{
“@type”: “Question”,
“name”: “How are reactions (emoji responses) stored and counted differently from binary votes?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Reactions extend the voting model from a binary up/down to a set of named reaction types (like, love, haha, wow, sad, angry). The vote record includes a reaction_type field, and the unique constraint covers (user_id, target_id) allowing only one reaction per user per target regardless of type, with a change of reaction updating the existing record. Count storage maintains one counter per (target_id, reaction_type) pair rather than a single aggregate. The reaction summary returned to clients is a map of reaction_type to count plus the requesting user’s current reaction. For storage efficiency, reaction counts per content item are serialized as a compact JSON object in a single row alongside the content record, updated atomically via optimistic locking, rather than maintaining one row per reaction type in a counts table.”
}
}
]
}

See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

Scroll to Top