InterviewKata
System design · Worked design D45 of 46 · Warm-up · Long form

Design a likes counter

Design the heart button on a photo-sharing app: tap to like, tap again to unlike, and a count under every photo. It sounds like count = count + 1. At 200,000 taps a second, with one photo going viral and every feed page showing 30 counts, it becomes a lesson in where the truth lives, how to count without locking, and how exact a number really needs to be.

Modules this design uses07 Idempotency · 09 Caching · 15 Sharded counters · 17 Contention · 18 Streams · Designs D17, D20

1. The prompt

"Design the like button and like counts for a photo-sharing app with hundreds of millions of users."

What makes it hard:

2. Frame the problem

Exactly how exact must the count be?

The person who tapped must see their own like immediately. Everyone else can see a count that's a few seconds old, and big counts are shown rounded ("1.2M").

That freedom is what makes batching and caching allowed.

Can people see who liked?

Yes, the list of likers, newest first, and "liked by Sam and 3,400 others".

So we keep one row per (user, photo), not just a number.

How much traffic?

500 M daily users, 5 B likes and unlikes a day, peaks at 4× average. Each user scrolls ~100 photos a day.

Writes ~60,000/s average; count reads ~50 B a day.

Can counts ever go down?

Yes: unlikes, deleted accounts, removed spam likes.

A pure increment-only counter is not enough; the truth must support removal.

Abuse?

Bots buying likes is a real business; suspicious likes are removed later.

Counts must be recomputable from the individual likes.

3. What it must do

  1. Like and unlike a photo; repeated taps and retries change nothing extra.
  2. Show counts for every photo on screen, and whether you liked each one.
  3. List who liked a photo, newest first.

Not now: other reactions (the same design with a reaction type), notifications to the owner (the notification system, D05), ranking feeds by likes (D06).

4. How well it must do it

TargetWhy this number
Your own like shows instantly and survives a refresh consistency"I liked it and it disappeared" is the bug users report.
Others see the count within 5 s freshnessNobody can tell a 3-second-old count from a live one.
Counts for a page of 30 photos in under 20 ms at p99 speedThey're on the feed's critical path.
Count = number of current likers, drifting by under 0.1% and repaired within a day data safetyCreators compare numbers; a wrong count is a trust problem.
One viral photo (50,000 likes/s) doesn't slow anything else load shapeThe hot key must not take its shard's neighbours down.

5. Numbers that change the design

QuantityWorking→ so we…
Like writes5 B a day ≈ 60,000/s, ~240,000/s at peak→ so the likes table is sharded; no global lock anywhere
Count reads500 M × 100 photos = 50 B a day ≈ 600,000/s, peaks over 2 M/s→ so counts come from a cache, fetched 30 at a time
One hot photo50,000 likes/s on one row; a single database row takes a few hundred to a few thousand locked updates a second→ so never update a count row per like: batch, and split hot counters (9.3)
Stored likes5 B a day × ~30 bytes, kept forever ≈ 55 TB a year→ so a wide-column or sharded store keyed by photo, with a second copy keyed by user

Illustrative numbers.

6. The nouns

likes_by_photo  (photo_id, user_id) → liked_at                 (partition by photo; list likers newest first)
likes_by_user   (user_id, photo_id) → liked_at                 (partition by user; "did I like these 30?")
photo_counts    (photo_id, shard 0..N) → count                 (N = 1 for most photos; more when hot)
count_cache     photo_id → count, as_of                        (Redis)

7. The contract

PUT    /v1/photos/{id}/like      → 200 {liked: true}    (idempotent: liking twice is still one like)
DELETE /v1/photos/{id}/like      → 200 {liked: false}
GET    /v1/photos/likes?ids=…    → [{photo_id, count, liked_by_me}]   (up to 50 per call)
GET    /v1/photos/{id}/likers?cursor=…  → users, next cursor

PUT and DELETE describe the state the user wants, not a change, so a retry after a timeout can't double-count; that is better than an idempotency key here. The user comes from the token.

8. First working version

A likes service writes the like row and, only if it changed, emits a like event. A counter consumes events and updates counts in batches. Feeds read counts from a cache in front of the count table.

8.1 Tapping the heart

8.2 Showing 30 counts on a feed page

Parked for laterdouble taps and retries (9.1) · counting without locks (9.2) · the viral photo (9.3) · counts that drift (9.4) · bought likes (9.5).

9. Make it hold up

9.1 Double taps, retries and the toggle data safety

The rule that fixes it: the count changes only when the like row changes, and by exactly that change. INSERT … ON CONFLICT DO NOTHING (or a conditional put in a key-value store) tells you whether a row was created; DELETE tells you whether one was removed. Emit +1 or −1 from that answer, never from the request.

9.2 Counting without locking speed

ApproachWhy
✗ BadSELECT COUNT(*) over likes on every viewA photo with 3 M likes means reading 3 M rows, 600,000 times a second.
~ GoodUpdate the like row and the count row in one transactionAlways exact, but every like waits for a lock on the count row; a viral photo serialises on it.
✓ GreatWrite the like; send the +1/−1 as an event; a counter adds up events per photo for a second and writes one update per photo per second; the tapper's own view comes from their like row (or the app), not from the countThe truth is written immediately; the count is seconds behind and one write a second per photo, however hot.

The counter must not double-apply a batch after a crash: it stores the stream offset in the same transaction as the counts (or keeps per-photo "last applied offset"), so a replay skips what was already counted (Module 18).

9.3 The viral photo load shape

Most photos never need this, so split counters are turned on per photo when its like rate crosses a threshold, and the count cache absorbs the reads: one cached value per photo, refreshed every second or two, with requests for the same key collapsed into one fetch (Module 09). The lab in section 13 shows how batching and splitting interact.

9.4 Counts that drift, and how to repair them data safety

However careful the pipeline, counts drift: a lost event, a bug in an old app version, likes removed by the spam team, an account deletion. Because every like is a row, the count can always be recomputed. A reconciler recounts photos that changed in the last day (cheap: only active photos) and writes corrections as ordinary events; a monthly sweep checks a sample of everything else. Alert when corrections exceed 0.1%: that means a bug, not noise.

9.5 Bought likes security

9.6 The whole design after the deep dives

10. Interviewer follow-ups

Show "liked by Sam and 3,400 others", where Sam is someone you follow.

For each photo on screen, intersect a small set: the people you follow (cached) with the photo's recent likers. For huge photos, check only your top ~50 closest accounts against likes_by_photo with point lookups. Cache the answer per (viewer, photo) for minutes.

Add reactions (heart, laugh, wow).

The like row gains a reaction type; changing reaction is −1 on the old, +1 on the new, computed from the row's previous value. Counts become per (photo, reaction).

Two regions, users liking the same photo in both.

Each region writes like rows locally (the key is unique per user, so conflicts only come from the same user in two regions at once, resolved by latest timestamp). Each region counts its own events; the displayed count sums the regional counts.

Why not just a Redis INCR?

It's fast, but alone it loses the who-liked list, can't tell a retry from a second like, and loses increments if a Redis node fails before persisting. Use it as the display cache, not the truth.

The counter falls an hour behind.

Likes are still saved and each user still sees their own; only others' counts are stale. Scale the counter consumers (partitioned by photo), and because they batch per photo, catching up is fast: an hour of events for a hot photo becomes a few thousand writes.

11. When things break

FailureWhat users seeHow we detect itWhat limits the damage
Counter lagCounts frozen for othersConsumer lagOwn likes come from like rows; batching catches up fast
Count cache node lostA burst of missesHit rateReplicas; coalesced refills from the count table
Event lost or applied twiceA count off by a fewReconciler correctionsRecount from like rows
Hot photo overwhelms a shardSlow likes on neighbouring photosPer-partition latencySplit counters, per-photo batching
Bot waveInflated countsLike-rate anomaliesRate limits; bulk removal flows through events

12. Wrap-up and the clock

Trade-offs we made:

What we'd watch: counter lag · cache hit rate · writes per count row · reconciler corrections · like rate per account.

At 10×: more partitions for like rows and more counter consumers; the per-photo batching means the count table barely grows.

A 45-minute interview

0–5Frame: exactness, toggles, likers list
5–10Numbers: reads vs writes, the hot photo
10–20Like rows, events, cached counts
20–38Deep dives: toggle (9.1), batching (9.2), hot photo (9.3)
38–45Drift, follow-ups, wrap-up

A 60-minute interview

0–6Frame and numbers
6–14Nouns, contract (state, not deltas)
14–24First working version
24–50Deep dives 9.1–9.5
50–60Follow-ups, trade-offs, 10×
LevelWhat a strong answer shows
Mid-levelA likes table, a count column, a cache in front, idempotent like/unlike.
SeniorDeltas from row changes, async batched counting with offsets, split counters for hot photos, cached batch reads, the tapper's view vs everyone else's.
Staff+Reconciliation as part of the design, abuse removal through the same pipeline, multi-region counting, choosing exactness per audience.

Common mistakes:

13. Try it, check yourself, read more

The hot-counter lab. One photo is getting likes fast. Choose how many counter rows it's split across and how long the counter batches before writing. A single row is comfortable up to about 500 locked updates a second here.

Likes per second on this photo
Counter rows (split)
Batch window

A simple model: without batching, every like is one update spread evenly over the rows; with batching, each row gets at most one update per window. Reading the count sums all rows (cached). The 500/s row limit is illustrative; measure your database.

When should the like count change?

The delta comes from the row change, so double taps and retries change nothing.

A photo gets 50,000 likes a second. How do you keep its count row from becoming a bottleneck?

Batching turns 50,000 updates into one per window; splitting spreads what remains.

The count is a few seconds behind. Where does "you liked this" come from, so the tapper never sees it vanish?

Read your own writes from the truth; let everyone else see the batched count.
ResourceWhy
PostgreSQL: INSERT … ON CONFLICTThe conditional write that tells you whether a like was new.
Firestore: distributed countersA product's write-up of split counters, with its per-document write limit.
Redis INCRBYThe display-cache side of counting.