# Low Level Design: Voting and Reaction System

Source: https://www.techinterview.org/post/3233471891/lld-vote-system/
Updated: 2026-07-13 · techinterview.org

## 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](https://www.techinterview.org/post/3233460272/meta-interview-guide-2026-facebook-instagram-whatsapp-engineering/)

See also: [Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence](https://www.techinterview.org/post/3233460274/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](https://www.techinterview.org/post/3233460275/scale-ai-interview-guide-2026-data-infrastructure-rlhf-pipelines-and-ml-engineering/)
