0) Problem Restatement
Design likes for a platform like Roblox (games) or any social app. Users like and unlike items. Each item shows a like count, and each user sees whether they liked it. Popular items get huge bursts ("a viral game gets 50,000 likes per second"). Reads far outnumber writes. A user can like an item at most once. Roblox asked this twice.
Asked at: Roblox — 2 candidate reports between Sep 2025 and Apr 2026.1) Requirements
like(user, item),unlike(user, item): idempotent.get_count(item)andhas_liked(user, item)(often for a list of 50 items at once).- Optional: list who liked an item, and "liked by 3 friends".
- Counts can be slightly delayed (seconds) but must eventually be accurate.
1.1 Scale Estimates
- 1B likes stored (~50 bytes each → 50 GB, sharded).
- Writes: 20K/sec normally, 100K/sec bursts, concentrated on a few hot items.
- Reads: 500K/sec (every page shows counts).
2) Data Model
likes: (item_id, user_id) PRIMARY KEY, created_at -- source of truth, one row per like
user_likes: (user_id, item_id), created_at -- "did I like it?" / "my liked items"
like_counts: item_id → count -- derived, cached
The primary key (item_id, user_id) guarantees one like per user per item. Inserting twice does nothing.
3) Architecture
Architecture Diagram
flowchart LR
U["Clients"] --> API["Likes API"]
API --> DB[("Likes DB - sharded by item_id")]
API --> K[("Like events")]
K --> AGG["Counter aggregator - batches +1/-1"]
AGG --> CNT[("Counts - Redis + DB")]
U -->|"read counts"| RD["Read API"]
RD --> CNT
RD --> UL[("user_likes cache")]4) Key Flows
4.1 Like
INSERT INTO likes (item_id, user_id) ... ON CONFLICT DO NOTHING.- If a row was actually inserted (not a duplicate), publish
{ item_id, +1 }. If it was a duplicate, do nothing, which makes the API idempotent. - Update
user_likesso the user immediately sees their own like (read-your-writes).
4.2 Unlike
Delete the row. If a row was deleted, publish { item_id, -1 }.
4.3 Counting without hot-row contention
If every like did UPDATE counts SET n = n + 1 WHERE item_id = X, a viral item's single row would become a bottleneck. Instead:
- Batch in the aggregator: consume events and sum them per item for ~1 second, then apply one
INCRBY item, +4312. Thousands of writes become one. - Sharded counters for extreme items: split the count into N sub-counters (
count:item:0..15). Writers pick one at random, and readers sum them (cache the sum briefly). - Store counts in Redis for fast reads, and persist them to the DB periodically.
4.4 Reading a page of 50 items
- One multi-get for 50 counts from Redis.
- One multi-get for
has_likedof those 50 for this user (a per-user set of recently liked items in cache, falling back touser_likes).
5) Accuracy and Reconciliation
- Counts are eventually consistent: the aggregator may lag a second or two.
- If events are lost or double-applied, counts drift. A nightly job recounts
SELECT count(*)per item (or per changed item) from the source-of-truth table and fixes the counter. - Clients show the user's own like instantly (optimistic UI), even before the count updates.
- For display, large numbers can be approximate ("1.2M"), which also hides small lag.
6) Trade-offs & Alternatives
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Truth | One row per (item, user) | Idempotent, auditable, supports "who liked" | Only a counter: can't prevent double likes |
| Counting | Async aggregation + batching | Handles hot items | Synchronous row increment: hot-row contention |
| Hot items | Sharded counters | Spreads writes | Single key: bottleneck |
| Correctness | Periodic recount | Fixes drift | Trust counters forever: slow drift |
7) Wrap-Up
Store each like as a row keyed by (item, user) so likes are idempotent, and keep a user-side index for "did I like it". Publish +1/-1 only when a row actually changes, aggregate those events in batches (with sharded counters for viral items) into Redis-backed counts, and serve pages with multi-gets. Accept a second or two of lag, show the user's own action instantly, and reconcile counts with a periodic recount.