← all topics Like-counter board · say it in this order 1 Require 4m 2 Entities 1m 3 API 3m 4 Design 10m 5 Dives ~20m reset
SAY THIS SENTENCE FIRST

"The like edge — who liked what — is the source of truth and must be exact. The count is a derived view of it and may lag a few seconds. Every later decision hangs off that split."

STEP 1 OF 5

Requirements

4 min
ASK, THEN COMMIT

Three questions. State your assumption for each so the interview moves even without answers.

  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.
  2. Does deleting an account have to erase its likes?  Assume yes, within 30 days, and the public count visibly drops.
  3. May the displayed count briefly dip or be a little stale?  Assume yes; a few seconds, always labeled.
Numbers to say

5B like events/day ≈ 58K/s average · peak 290-580K/s · reads 10-100x writes · ~1011 stored edges ≈ tens of TB · write tier ≈ $400K/month at list prices (each action = edge write + retry-protection write)

Hard rules

A user's own like shows instantly and is always right. Retries and duplicates must never change the count. The count may lag seconds but must say so.

DONE WHEN: interviewer nods at the three assumptions.
STEP 2 OF 5

Core entities

1 min
  • Like edge: one row per (item, user). It has a state, liked or unliked, instead of being created and deleted. This row is the truth.
  • Count: one number per item, computed FROM the edges. Derived, never the truth.
  • Proposal-to-retract: the abuse hook. The abuse system can flip edges through the same path as users.
DONE WHEN: you have said the opening sentence again, pointing at edge vs count. Schema waits for dive B.
STEP 3 OF 5

API

3 min
POST   /v1/items/{itemId}/likes     Idempotency-Key: key   // one key per tap, kept across retries
DELETE /v1/items/{itemId}/likes/{userId}   same key rules
  → 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 }     // exact for the asking user
GET    /v1/items/{itemId}/likes?cursor=&limit=              // who liked, paginated
  • The acting user comes from the login session, never from the request body.
  • Same key + same request = the server replays its first answer and changes nothing.
  • Why 200 on a no-op: like and unlike are target states, not events. Asking again for a state you already have is harmless, which is what makes retries safe.
STEP 4 OF 5

High-level design

10 min, end to end, no dives yet
Whiteboard version: 6 boxes, this draw order
client → gateway → like service → EDGE STORE (truth)       -- row 1
EDGE STORE ↓ change feed → dedupe → re-key by item → 1s totals → counter   -- row 2
readers → caches → counter; own button → session cache          -- row 3
reconciliation, regions, hot-item armor: only if asked
Like counter architecture
THE WALKTHROUGH, ONE BREATH PER CLAUSE

A like is one conditional write on its edge row, which is the source of truth → dive A. The database's change feed streams every committed flip into Kafka; a processor drops duplicates, regroups events by item, adds them up once per second, and writes each second's total to the counter with a fence so replays cannot double-count → dive C. Product pages read the counter through caches; the tapping user's own button reads a small session cache, so it is instant and exact → dive D. A reconciliation job recounts edges and heals the counter through the same pipeline.

DONE WHEN: the interviewer picks a box to open. Let them steer from here.
  • If they just nod and wait: "the riskiest part is making the count exactly right under retries, shall I open that?"
  • Running behind: protect dives A and C. Cut F first.
  • If asked "why streaming at all": at 5B events/day the count cannot be computed per read; something must maintain it, and the stream is also what the abuse and trending systems will feed on.
STEP 5 OF 5

Deep dives: the core four

~20 min, interviewer steers
TRIGGER: "how do retries not double-count" / "walk me through a like"
A · Idempotency, end to end
ONE ANSWER IN 3 LAYERS · each layer protects a different thing
Layer 1: the row itself
  • One row per (item, user), enforced unique by the database.
  • A second like from the same user updates that row. It can never create a second row.
Layer 2: the conditional write
  • The write says: set state to LIKED only if it is currently UNLIKED (or the row is new).
  • A retry finds state already LIKED, changes nothing, still gets 200.
  • Each real flip also bumps a per-row version number (op_id), atomically, in the same write.
Layer 3: the request key
  • Each tap gets a key; retries of that tap reuse it.
  • The server keeps (user, key) → answer for a day and replays the stored answer.
  • This closes the gap layer 2 cannot: like → answer lost → user unlikes → old retry arrives. Without the key, that stale retry would re-like.
TRIGGER: "one post goes viral" / "what melts first"
B · Data model and the hot-item armor
ONE ANSWER · the key design IS the defense
The edge table
  • Row key: item + a bucket number, where bucket = hash(user) % 64.
  • So one viral item's writes spread over 64 different storage partitions.
  • Why needed: a single partition takes about 1K writes/sec; a viral item does ~17K/sec. Without buckets, the source of truth melts first and nothing downstream matters.
What the bucket costs
  • "Show me every liker of this item" now has to ask all 64 buckets.
  • Acceptable: that query is a rare admin and recount path, not a user path.
Erasure
  • Deleting an account hard-deletes its edge rows and its retry-protection rows.
  • Each deletion flows through the pipeline as a minus-one, so counts drop honestly.
TRIGGER: "exactly once?" / "the processor crashes" / "why not count in the stream"
C · The pipeline: from edge flips to an exact count
ONE PIPELINE IN 4 STAGES · read top to bottom
Stage 1: the change feed
  • The edge database publishes every committed row change, in order, per row.
  • Events go into Kafka grouped by item + hash(user), the same deterministic rule everywhere.
  • Deterministic matters: a retried event lands in the same group and meets the duplicate-check that already saw it. A random spread would dodge it and double-count.
Stage 2: drop duplicates
  • The processor remembers the last version number (op_id) it applied per edge.
  • An event at or below that number has been seen; drop it.
  • This memory only needs to cover the replay window (7 days), not all 1011 edges.
Stage 3: regroup by item, add up one second at a time
  • All 64 buckets' events for an item come back together at one worker.
  • It emits one total per item per second: "+8,214" instead of 8,214 separate writes.
  • Say why the regroup is stated: without it, 64 workers each write partials, and any per-item guard accepts the first and wrongly rejects the other 63.
Stage 4: the fence at the counter
  • The counter row remembers the last second it applied: ADD the total, SET last_window, but only if this second is newer.
  • One atomic statement. A replayed second fails the condition and does nothing.
  • So the guarantee lives at the LAST write. Everything upstream may replay freely.
TRIGGER: "millions of reads per second" / "the user taps like and sees what?"
D · Reads, and the instant own-button
TWO SEPARATE READ PATHS · they never mix
The count, cheap
  • Page loads read: CDN cache (a few seconds) → count service → Redis → counter store.
  • Most reads never reach a database.
  • Viral item + caches expiring together = a stampede. Two standard brakes: serve the stale copy while refreshing, and let only one refresh per item through at a time.
The own-button, exact
  • On every like, the service also writes a small per-user session cache: "you liked X just now."
  • Button state = that cache, layered over an ordinary edge read.
  • So the tapping user sees the flip instantly even while the public count lags.
Feed pages
  • One batch call returns N counts + N button states together.
STEP 5, CONT.

Three more dives

rehearse after the core four
TRIGGER: "counter store dies" / "counts drift" / "a celebrity deletes their account"
E · Repair: rebuild, reconcile, mass-delete
3 SEPARATE SCENARIOS · each self-contained
Counter store lost
  • It is derived, so rebuild it: 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. The edge store is the rebuild source.
Slow drift
  • A job recounts an item from a frozen snapshot of the edges, waits until the pipeline has caught up past that snapshot point, then sends the difference as a correction through the normal pipeline.
  • Through the pipeline, so it composes with live traffic instead of overwriting it.
  • Clamp at zero and alert: a negative count is a bug report, not a number.
Account deletion at celebrity scale
  • 50M unlikes flow as ordinary minus-ones, spread across buckets, rate-limited.
  • No special path; the erasure job is just a big polite user.
TRIGGER: "multi-region?" / "a region dies"
F · Regions, honestly
ONE COMMITMENT + ITS FAILURE STORY
The commitment
  • Each item range has one home region that owns its writes; other regions forward writes there and serve reads locally, labeled stale.
  • Why not active-active: two regions writing the same edge resolve by "last writer wins," which silently breaks exactness.
Failover
  • Every range carries an era number (epoch). Every conditional write requires the current era.
  • To move a range: bump the era first. The old home's in-flight writes now fail their era check and die cleanly instead of forking history.
  • The unreplicated tail becomes a reconciliation correction, never a merge.
TRIGGER: "how do you run this" / "what do you watch"
G · Operations
4 SEPARATE TOPICS
Targets
  • Like-write availability 99.95%, p99 under 150ms.
  • Count staleness p99 under 5s, measured from event time at the sink, not guessed.
Alerts that map to those targets
  • Sink lag (staleness burning), consumer lag, per-bucket write throttles (armor breached), reconciliation mismatch (any nonzero pages), retry-protection error rate.
When overloaded
  • Fail like-writes fast with retry-after; never queue them. Reads keep serving.
  • Counts go visibly stale rather than wrong; own-buttons stay correct via the session cache.
Rolling out the pipeline
  • Run old and new side by side in shadow; diff daily; cut over at a marked stream position by initializing each counter's fence there. Rollback is the same move at another mark.
FLASHCARDS

The five hardest probes

show all (interview mode)
One second of the viral item, end to end.
16.7K events land across 64 dedupe workers. The regroup step brings all their deltas for that item to ONE worker. It emits exactly one (item, second, +N). The sink is one atomic update with the fence "only if this second is newer" on the counter row itself, so there is no gap between check and add. Crash anywhere: the replayed second re-presents, fails the fence, the count stays right.
Why must the Kafka grouping be a hash of the user, never random?
Because the duplicate-check memory lives with the group. A deterministic rule sends a retried event to the same worker that already saw it. A random spread sends it to a fresh worker with no memory of it, which applies it again: guaranteed double count on exactly the retry paths that matter.
The processor crashes 3 seconds after its last checkpoint. What does the count read?
Its memory and reading position rewind together. It re-reads the same events, rebuilds the same per-second totals, and re-offers them to the counter. Each replayed second fails the counter's fence and does nothing. The count is at most seconds stale and never double-counted.
A like's 200 is lost, the user unlikes, the client retries the old like.
The retry carries the original key. The server finds (user, key) in its retry-protection table and replays the stored 200 without touching state. The row stays UNLIKED, matching what the user last chose. State checks alone would have re-applied the like; the key is what makes it safe.
Home region for a hot range goes dark mid-write. Move it without forking history.
Bump the range's era number first, at the new home. The old home's in-flight conditional writes now carry a stale era and fail closed. Then serve writes at the new home. The old home's unreplicated tail is reconciled later as corrections through the pipeline, never merged. On-call sees the old-home rejection spike first, then traffic shift.