"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."
Requirements
4 minThree questions. State your assumption for each so the interview moves even without answers.
- Is the real problem counting, or fake likes (bots, bought likes)? Assume counting, with a hook so an abuse system can retract likes later.
- Does deleting an account have to erase its likes? Assume yes, within 30 days, and the public count visibly drops.
- May the displayed count briefly dip or be a little stale? Assume yes; a few seconds, always labeled.
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)
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.
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.
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.
High-level design
10 min, end to end, no dives yetclient → 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

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.
- 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.
Deep dives: the core four
~20 min, interviewer steers- 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.
- 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.
- 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.
- 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.
- "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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- One batch call returns N counts + N button states together.
Three more dives
rehearse after the core four- 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.
- 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.
- 50M unlikes flow as ordinary minus-ones, spread across buckets, rate-limited.
- No special path; the erasure job is just a big polite user.
- 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.
- 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.
- Like-write availability 99.95%, p99 under 150ms.
- Count staleness p99 under 5s, measured from event time at the sink, not guessed.
- Sink lag (staleness burning), consumer lag, per-bucket write throttles (armor breached), reconciliation mismatch (any nonzero pages), retry-protection error rate.
- 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.
- 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.
The five hardest probes
show all (interview mode)Read the Step 4 walkthrough aloud once, timing yourself. If it runs past 90 seconds, cut words until it fits.