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.

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