system design

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 are cached in Redis with key vc:{item_type}:{item_id}, TTL 60 seconds, invalidated on every write.
  • User vote state is cached per user per item with key uv:{user_id}:{item_type}:{item_id}, TTL 5 minutes.
  • The reaction registry is cached globally for 1 hour and 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 partitions the votes table by item_id % N so that hot items are distributed across shards.
  • For extremely viral items, write batching buffers writes in a Kafka topic and applies them in micro-batches to vote_counts, trading a few seconds of staleness for dramatically lower write pressure on the database.
  • For 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 are handled by the upsert; only one row exists per user-item pair.
  • For a vote on a deleted item, a foreign-key constraint or a pre-write existence check prevents orphaned vote rows.
  • If a reaction type is retired mid-flight, the API validates reaction codes against the registry before inserting, and retired codes return 400.
  • During a network partition on write, idempotency keys on the POST endpoint allow clients to safely retry without double-counting.

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

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

1972 Soviet postage stamp commemorating the Mars 2 probe

worth a read

Mars For The Rest of Us — a weekly-or-more deep dive on the technical side of Mars exploration: rocket propulsion, microbiology, mission architecture, and everything in between. Written by Maciej Ceglowski.

Read it on Substack →
Scroll to Top