CASE STUDY

Scalable Likes System and Like Counter

4 min read·644 words·Intermediate

How to use this case study

SDE-2 / Mid

Design the likes table (one row per user and item), the like/unlike APIs, and how the count is shown.

SDE-3 / Senior

Handle hot items with bursts of likes (sharded counters, write buffering), idempotent like/unlike, caching counts and reconciliation.

Staff / Principal

Discuss "liked by friends", consistency expectations, multi-region, and the cost of exact vs approximate counts at huge scale.


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) and has_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

  1. INSERT INTO likes (item_id, user_id) ... ON CONFLICT DO NOTHING.
  2. 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.
  3. Update user_likes so 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_liked of those 50 for this user (a per-user set of recently liked items in cache, falling back to user_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

DecisionChoiceWhyAlternative
TruthOne row per (item, user)Idempotent, auditable, supports "who liked"Only a counter: can't prevent double likes
CountingAsync aggregation + batchingHandles hot itemsSynchronous row increment: hot-row contention
Hot itemsSharded countersSpreads writesSingle key: bottleneck
CorrectnessPeriodic recountFixes driftTrust 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.

More Case Studies

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with our AI interviewer.

Start System Design Interview →