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.
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:
- A toggle, not a counter. Like, unlike, like again, double taps, retries on a bad network: the count must still match the number of people who currently like the photo.
- One very hot photo. A celebrity post can take 50,000 likes a second, all aimed at a single number.
- Reads dwarf writes. Every photo in every feed shows a count, so counts are read far more often than they change.
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
- Like and unlike a photo; repeated taps and retries change nothing extra.
- Show counts for every photo on screen, and whether you liked each one.
- 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
| Target | Why 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 freshness | Nobody can tell a 3-second-old count from a live one. |
| Counts for a page of 30 photos in under 20 ms at p99 speed | They're on the feed's critical path. |
| Count = number of current likers, drifting by under 0.1% and repaired within a day data safety | Creators compare numbers; a wrong count is a trust problem. |
| One viral photo (50,000 likes/s) doesn't slow anything else load shape | The hot key must not take its shard's neighbours down. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Like writes | 5 B a day ≈ 60,000/s, ~240,000/s at peak | → so the likes table is sharded; no global lock anywhere |
| Count reads | 500 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 photo | 50,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 likes | 5 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
- Like: one row per (photo, user) that currently likes it. This is the truth.
- Count: the number of like rows for a photo. Derived, cached, and always recomputable.
- Like event: "user U liked (or unliked) photo P at time T", produced only when the like row actually changed.
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
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
| Approach | Why | |
|---|---|---|
| ✗ Bad | SELECT COUNT(*) over likes on every view | A photo with 3 M likes means reading 3 M rows, 600,000 times a second. |
| ~ Good | Update the like row and the count row in one transaction | Always exact, but every like waits for a lock on the count row; a viral photo serialises on it. |
| ✓ Great | Write 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 count | The 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
- Rate limits per account and per device (Design D02): a human can't like 300 photos a minute.
- Scoring new and suspicious accounts; their likes count for the owner's eyes but may be held back from ranking until trusted.
- Removal in bulk: when a bot network is found, its like rows are deleted and the deletions flow through the same event path, so counts fall back automatically.
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
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Counter lag | Counts frozen for others | Consumer lag | Own likes come from like rows; batching catches up fast |
| Count cache node lost | A burst of misses | Hit rate | Replicas; coalesced refills from the count table |
| Event lost or applied twice | A count off by a few | Reconciler corrections | Recount from like rows |
| Hot photo overwhelms a shard | Slow likes on neighbouring photos | Per-partition latency | Split counters, per-photo batching |
| Bot wave | Inflated counts | Like-rate anomalies | Rate limits; bulk removal flows through events |
12. Wrap-up and the clock
Trade-offs we made:
- Like rows as the truth, counts derived: more storage, but every count can be repaired.
- Counts seconds behind for others, instant for the tapper.
- Batching per photo and split counters only for hot photos.
- PUT/DELETE of a state instead of "add one", so retries are harmless.
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–5 | Frame: exactness, toggles, likers list |
| 5–10 | Numbers: reads vs writes, the hot photo |
| 10–20 | Like rows, events, cached counts |
| 20–38 | Deep dives: toggle (9.1), batching (9.2), hot photo (9.3) |
| 38–45 | Drift, follow-ups, wrap-up |
A 60-minute interview
| 0–6 | Frame and numbers |
| 6–14 | Nouns, contract (state, not deltas) |
| 14–24 | First working version |
| 24–50 | Deep dives 9.1–9.5 |
| 50–60 | Follow-ups, trade-offs, 10× |
| Level | What a strong answer shows |
|---|---|
| Mid-level | A likes table, a count column, a cache in front, idempotent like/unlike. |
| Senior | Deltas 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:
- Incrementing on every request, so double taps and retries inflate counts.
- One count row updated per like, locked by every liker of a viral photo.
- Only a counter, with no like rows: no likers list, no undo, no repair.
- Fetching counts one photo at a time.
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.
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?
A photo gets 50,000 likes a second. How do you keep its count row from becoming a bottleneck?
The count is a few seconds behind. Where does "you liked this" come from, so the tapper never sees it vanish?
| Resource | Why |
|---|---|
| PostgreSQL: INSERT … ON CONFLICT | The conditional write that tells you whether a like was new. |
| Firestore: distributed counters | A product's write-up of split counters, with its per-document write limit. |
| Redis INCRBY | The display-cache side of counting. |