← all topics

Design a Like Counter

Hello Interview structure · plain notes, one idea per line

Understanding the Problem

👍 What are we building?
  1. Is the real problem counting, or fake likes (bots, bought likes)? Assume counting, with a hook so an abuse system can retract likes later through the same path.
  2. Does deleting an account have to erase its likes? Assume yes, within 30 days, and the public count visibly drops. Surface that this is a product decision: a celebrity deletion visibly drains 50M likes off a public number.
  3. May the displayed count briefly dip or be a little stale? Assume yes: a few seconds, always labeled.

Functional Requirements

  1. Users should be able to like and unlike an item, and retries or duplicates must never change the count.
  2. Users should see an item's like count. It may lag a few seconds, but it must say so.
  3. The acting user should see their own like instantly, and it is always right.
  4. Users should be able to list who liked an item (paginated), and account deletion must erase likes with the public count dropping honestly.

Below the line (out of scope):

Non-Functional Requirements

Do the scale math first, because it picks the architecture
  1. The edge is exact. Retries, duplicates, crashes, and replays never change what the count converges to.
  2. The count is eventually consistent with a stated bound: staleness p99 under 5 seconds, measured at the sink from event time, not guessed, and labeled asOf.
  3. Like-write availability 99.95%, p99 under 150ms. Under overload, fail writes fast with retry-after rather than queueing them; reads keep serving.
  4. Read-your-writes for the actor (their own button), eventual for everyone else.

The Set Up

Defining the Core Entities

API or System Interface

POST   /v1/items/{itemId}/likes            // Idempotency-Key: one key per tap, kept across retries
DELETE /v1/items/{itemId}/likes/{userId}   // same key rules; userId must match the session
  → 200 either way                        // liking twice, or unliking nothing, is fine by design
GET    /v1/items/{itemId}/count            // → { count, asOf } - approximate, labeled
GET    /v1/items/{itemId}/likes/{userId}   // → { liked } - read-your-writes for the actor, else eventual
GET    /v1/items/{itemId}/likes?cursor=&limit=   // who liked, paginated
POST   /v1/likes:batchGet   { itemIds: [...] }   // feed pages: N counts + N button states in one call

High-Level Design

1) Users can like and unlike, and retries never change the count

lc-hi1

2) Users see a count that lags seconds, and says so

lc-hi2

3) The actor sees their own like instantly

lc-hi3

4) Who liked an item, and erasure that drops the count honestly

lc-hi4

Potential Deep Dives

1) How do retries and duplicates never change the count?

Bad Solution: dedupe requests in application memory
  • The service remembers recent request ids in memory and drops repeats. A restart forgets everything; a retry that lands on another node was never seen.
  • Worse, any read-then-write ("is it liked? no? then like it") is check-then-act: two concurrent taps both read UNLIKED and both apply.
Good Solution: put idempotency in the data model
  • Layer 1, the row: one row per (item, user), enforced unique by the database. A second like updates that row; it can never create a second row.
  • Layer 2, the conditional write: set state to LIKED only if it is currently UNLIKED (or the row is new). A retry finds LIKED, changes nothing, still gets 200. Each real flip bumps op_id atomically in the same write (DynamoDB: if_not_exists(op_id,0)+1 inside the conditional UpdateItem; a no-op emits no stream record).
  • This makes double-likes harmless, but it cannot tell a stale retry from a genuine new intent.
Great Solution: add the request key, and name the hole it closes
  • Layer 3: each tap gets a key; retries of that tap reuse it. The server keeps (user, key) → answer for about a day and replays the stored answer without touching state.
  • The hole it closes: like → 200 lost → user unlikes → the old retry arrives. State checks alone see UNLIKED and would re-apply the like, flipping the row against the user's last choice. The key makes that retry replay its original 200 instead; the row stays UNLIKED.
  • Say where each layer's guarantee lives: the row kills duplicates of existence, the conditional write kills duplicates of transition, the key kills duplicates of intent.

2) One post goes viral. What melts first?

Bad Solution: partition the edge table by item_id and defend everything downstream
  • The viral item's 17K conditional writes/s land on ONE partition that takes about 1K/s. The source of truth melts before Kafka or the counter ever see load.
  • Defending the stream and the counter while the truth store dies is defending the wrong tier.
Good Solution: bucket the truth store's key
  • Row key item + hash(user) % 64: one viral item's writes spread over 64 partitions, ~260/s each. Deterministic by user, so (item, user) uniqueness and per-edge ordering survive.
  • Name the cost out loud: the full who-liked scan now fans out to 64 buckets (rare path), and the recount job inherits the same fan-out.
Great Solution: one lever per tier, and the keying doubles as the stream's dedupe locality
  • Truth store: the bucket key (above). Stream: the SAME item#bucket rule keys Kafka, so a retried event meets its own dedupe memory. Counter: windowed aggregation means the counter row sees one write per item per second, far under any per-row limit.
  • If a single counter row ever needs sharding anyway, the fence must shard with it deterministically: route windows by shard = window_id % K, never randomly, or a replayed window can land on a different shard and pass that shard's fence. One sentence, but it is the difference between sharded and broken.
  • The tuning dial to watch is per-bucket write throttles: a throttle firing means the armor is breached and the bucket count needs to grow for that keyspace.

3) Exactly once: crash the processor mid-window and tell me the count

Bad Solution: count in the stream processor and INCRBY the store
  • Input dedupe protects the processor's input; nothing protects its output. After a crash, state and offsets restore together, the same windows are rebuilt, and the increments already applied to the counter apply a second time.
  • "Offsets plus dedupe give effectively-once" attributes the guarantee to the wrong mechanism.
Good Solution: idempotent sink via a guard table keyed (item, window)
  • Right instinct, two real defects. The guard write and the counter write are separate tables: a crash between them either loses a window or double-applies it, which violates the design's own "never check-then-act" maxim at its most important write.
  • And the guard table grows by ~109 rows a day unless it gets a TTL at least as long as the replay horizon.
Great Solution: the fence lives on the counter row itself
UPDATE counter SET
  count = count + :n,
  last_window = :w
CONDITION last_window < :w
-- one atomic statement; a replayed window fails the condition and does nothing
  • Guard and increment are one atomic update on one row; the side table is deleted from the schema. The guarantee lives at the last write, so everything upstream may replay freely.
  • The one-second viral trace, end to end: 16.7K events land across 64 dedupe workers; the keyBy(item) regroup brings that item's deltas to ONE worker; it emits exactly one (item, second, +N); the sink is one atomic update with the fence on the counter row, so there is no gap between check and add. Crash anywhere: the replayed second re-presents, fails the fence, the count stays right.
  • The crash trace in one breath: the processor's memory and reading position rewind together to the last checkpoint, it re-reads the same events, rebuilds the same per-second totals, re-offers them, and each replayed second bounces off the fence. The count is at most seconds stale and never double-counted.
  • Kafka honesty: the tailer's producer needs enable.idempotence=true (ordering preserved with up to 5 in flight); there is no per-key in-flight knob. Dedupe memory is TTL'd to the 7-day replay window, with reconciliation named as the backstop past it.

4) The counter store dies. Counts drift. A celebrity deletes their account.

Bad Solution: recount the edges and overwrite the counter
  • The recount reflects time T; the overwrite lands at T+delta and destroys every delta applied during the scan, worst on hot items, which the job prioritizes. Reconciliation becomes the design's best corruption vector.
Good Solution: rebuild from the truth, replay the bounded tail
  • Counter store lost: recount the edges in parallel, then replay the last 7 days of stream on top. Full-history replay does not exist; the stream keeps 7 days, and the edge store is the rebuild source.
  • Take the recount from a frozen snapshot AT the replay start position, and initialize each rebuilt counter's fence at that same position, so the replayed windows apply exactly once. A live recount with the fence set 7 days back would count the tail twice.
Great Solution: fenced corrections through the pipeline, never overwrites
  • Drift: recount an item from a frozen snapshot of the edges (PITR/snapshot export at stream position T), wait until the sink watermark passes T, then send the difference as a correction through the normal pipeline. The correction enters the stage-2 keyed stream as its own window, so the row fence serializes it with live traffic instead of overwriting it.
  • Clamp at zero and alert: a negative count is a bug report, not a number.
  • Celebrity deletion needs no repair machinery at all: 50M unlikes flow as ordinary rate-limited minus-ones. The erasure job is just a big polite user.

5) Multi-region, honestly

Bad Solution: active-active replication of the edge store
  • Two regions writing the same edge resolve by last-writer-wins, which silently breaks the exactness the whole design is built on. Cross-region conditional writes are impractical; global tables are LWW.
Good Solution: one home region per item range
  • Each item range has one home region that owns its writes; other regions forward writes there (priced: far users pay a cross-region hop on writes) and serve reads locally, labeled stale.
  • Reads never need the home region; only the write path does.
Great Solution: epoch-fenced pin moves, with the on-call signal named
  • Every range carries an era number (epoch), and every conditional write requires the current era. To move a range: bump the era FIRST at the new home. The old home's in-flight writes now carry a stale era and fail closed instead of forking history.
  • The unreplicated tail from the old home becomes a reconciliation correction through the pipeline, never a merge.
  • What on-call sees, in order: the old-home rejection spike first, then traffic shifting to the new home. That ordering is the health check for the move itself.
  • One honest open point, stated rather than hidden: where the epoch check lives. A conditional write evaluates against the row being written, so either every edge row carries the epoch attribute (with a bump-on-first-write migration story) or the writer checks an out-of-band range lease, and that check must not reintroduce check-then-act. The fencing-token shape is right; commit to a storage location when asked.

6) How do you run it? SLOs, alerts, backpressure, and the cutover

Final Design

lc-arch

What is Expected at Each Level