SYSTEM DESIGN · DESIGN PROBLEMS · COUNTING AND RANKING · MEDIUM · 33 OF 41

Design a Likes Counter: Idempotent Likes, Hot Posts and Sharded Counters

How to design the like button and its count for a large social app: one like per user, counts that are a few seconds behind but never drift, a write path through an event log and a batching aggregator, sharded counters for viral posts, outbox and offsets for exactly-once counting, a reconciliation job, and a cached read path that serves feed-sized batches.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 20

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Start by listening. Users can like and unlike a post, and a user counts once, no matter how many times they tap. Every post in the feed shows its count and whether you liked it. And you can open the list of people who liked a post, page by page.

Now the qualities. Likes must almost never fail. The public count can be a few seconds behind, which is eventual consistency, but it must end up exactly right, never drifting. Your own like must show immediately, which is read your writes. Out of scope: the feed itself, ranking, notifications and emoji reactions.

Functional
  • like and unlike a post
  • one like per user per post
  • show the like count on every
  • post in the feed
  • show "you liked this"
  • list who liked a post
  • (paged, newest first)
Non-functional
  • highly available: a like
  • should never fail visibly
  • count may lag by seconds,
  • but must converge exactly
  • my own like shows instantly
  • out of scope: ranking, feed,
  • notifications, reactions

Ask scale

Ask about scale. Say two hundred million daily users, each liking five posts: a billion likes a day, about ten thousand writes a second, fifty thousand at peak. Each user sees fifty posts, so ten billion count reads a day, around a hundred thousand a second. Each like row is about fifty bytes, so eighteen terabytes a year.

Fifty thousand writes a second across a billion posts is easy to spread. The number that shapes the design is different: one celebrity post can get twenty thousand likes a second, all hitting the same key. Ask the interviewer for that peak. It decides whether a simple counter column survives.

Back of the envelope
200M daily users
likes:  5 per user per day = 1B/day
        1B / 10⁵ s ≈ 10,000 writes/s
        peak ×5 ≈ 50,000 writes/s

counts: 50 posts seen per user = 10B/day
        ≈ 100,000 reads/s, peak 500K/s

like row ≈ 50 bytes
1B × 50 B = 50 GB/day ≈ 18 TB/year

Define API

The API. Use PUT and DELETE on the like, not POST, because they are naturally idempotent: tapping twice leaves one like. The feed needs twenty counts at once, so offer a batch read that also returns liked by me. Likers are paged with a cursor. The user always comes from the auth token, never the request body.

API
PUT    /v1/posts/{id}/like
       → 200 { liked: true }
DELETE /v1/posts/{id}/like
       → 200 { liked: false }

GET /v1/likes/counts?posts=1,2,..20
    → [{ post, count, liked_by_me }]

GET /v1/posts/{id}/likers?cursor=..

user id comes from the auth token

Data model

Two kinds of data. The likes table is the truth: partition by post id, sort by user id, so the primary key itself guarantees one like per user. A wide-column store like Cassandra or DynamoDB fits, because it is huge, simple and write-heavy. The count lives separately, one small row per post, so reading it never means counting a billion rows.

Data model
likes  (wide-column / DynamoDB)
  PK post_id, SK user_id
  created_at
  → one like per user, free

post_counts
  post_id → count, updated_at

user_likes (optional)
  PK user_id, SK post_id
  → "posts I liked" page

High-level design

The write path. The like service does a conditional insert into the likes store. Only if the row is new does it publish a plus one event to the log, keyed by post id. An aggregator reads the log, adds up deltas per post for about a second, and writes one increment. Unlikes do the same with minus one.

The read path. The service asks the count cache for all twenty posts in one round trip. Misses fall back to the counts store and fill the cache. The aggregator writes new totals straight into the cache, so popular posts stay hot and fresh. Liked by me is a point lookup in the likes store, by post id and user id.

Deep dive: hot posts

Deep dive one: hot posts. If every like ran update count equals count plus one, every write would take a lock on the same row. A single row manages a few thousand updates a second at best. At twenty thousand, requests queue, time out and retry, and the whole shard holding that post slows down for everyone else.

First move: batch. Because the log is keyed by post id, all likes for one post land on one partition and one aggregator. It keeps a small map of pending deltas and flushes once a second. Twenty thousand writes become one write of plus twenty thousand. The price is a count that lags by about a second, which we already agreed is fine.

Second move, for the truly extreme posts: split the counter. Give a hot post sixteen sub-counters and send each increment to a random one. Writes spread sixteen ways. Reading means summing sixteen keys, so cache that sum for a second. Only promote posts past a threshold; the other billion posts keep a single key.

Why the naive way breaks
  • UPDATE count = count + 1
  • takes a row lock each time
  • one row ≈ a few thousand
  • updates/s at best
  • viral post: 20,000/s
  • → timeouts, retries, more load
  • and the shard holding it
  • slows down every other post
Move 1: batch the deltas
  • log keyed by post_id
  • → one partition owns a post
  • aggregator keeps a map
  • post_id → pending delta
  • flush every 1 s
  • 20,000 writes/s → 1 write/s
  • cost: count lags up to ~1 s
Move 2: sharded counters
hot post gets N sub-counters
  likes:42:0 … likes:42:15

write: INCR likes:42:{rand 0-15}
read:  sum of 16 keys
       (cache the sum for 1 s)

only for posts above a threshold
→ normal posts keep 1 key

Deep dive: exactly once

Deep dive two: counting exactly once. Phones retry, people double tap. The rule: count state changes, not requests. The conditional insert either creates the row, which emits plus one, or finds it already there, which emits nothing. Unlike works the same way: only a real delete emits minus one. Retries become harmless.

Two gaps remain. If the service crashes between the insert and the publish, the plus one is lost. Fix it by feeding the log from the likes table itself, with change data capture or an outbox, as in Concepts episode 33. And if the aggregator crashes mid-batch, store the last applied log offset together with the count, so a replay skips what was already added.

Even with all that, bugs happen. Add a reconciliation job: for recently active posts, count the rows in the likes store and compare with the counter. Small posts can be recounted directly. Any drift gets corrected and, more importantly, alerted on, because drift means a bug in the fast path.

Gaps and how to close them
  • service dies after insert,
  • before publishing the +1
  • → use change data capture or
  • an outbox: the log is fed
  • from the likes table itself
  • aggregator crashes mid-batch
  • → store last applied offset
  • with the count, in one write
  • → replays skip applied offsets

Deep dive: fast reads

Deep dive three: reads. Batching twenty counts per feed page turns half a million count reads into twenty-five thousand cache calls. Hot posts stay in the cache because the aggregator overwrites them. Big numbers are rounded, so one point two million can safely be a second old. Your own like appears instantly because the app updates optimistically.

Read path at 500K/s
  • MGET 20 counts per feed page
  • → 25,000 cache calls/s, not 500K
  • cache hot posts with no TTL;
  • aggregator overwrites them
  • show 1.2M, not 1,204,331
  • → big counts can be stale
  • my own like: optimistic UI +
  • liked_by_me from likes store

Trade-offs

Trade-offs and failures. If the cache dies, read the counts store with a rate cap and accept stale numbers. If the aggregator lags, counts are late but no like is lost. If the log is down, the outbox holds events. The only part that blocks the user is the likes store write, so give it the most care: replication and quorum writes.

What to monitor: aggregator lag in seconds, drift found by the recount, hot keys per cache shard, and the p ninety-nine of like writes. At ten times the traffic, the design holds: add log partitions, promote hot posts to sharded counters automatically, and move old likes to cheaper cold storage.

Failure modes
  • cache down → read counts store
  • (cap QPS, serve stale)
  • aggregator lag → counts late,
  • likes still safe in the store
  • log down → outbox keeps events
  • until it recovers
  • likes store slow → the only
  • path that blocks the user
Monitor and grow
  • monitor:
  • aggregator lag (seconds)
  • recount drift per day
  • hot keys per cache shard
  • like write p99 and errors
  • at 10x:
  • more log partitions
  • auto-promote hot counters
  • move old likes to cold storage

Say it like this

In the interview, say it like this. The likes table is the truth, keyed by post and user, so duplicates are impossible. Only real state changes emit plus or minus one into a log keyed by post. An aggregator batches them each second into a counter and the cache. Hot posts get sharded counters, and a recount catches any drift.

Interview summary
  • PUT/DELETE like: idempotent
  • likes table = truth, PK
  • (post_id, user_id)
  • emit ±1 only on state change
  • log by post_id → aggregator
  • batches 1 s → counts + cache
  • hot posts: sharded counters
  • CDC/outbox + offsets: no loss
  • nightly recount catches drift

Interview questions

Why not just run UPDATE posts SET likes = likes + 1 on every like?

Every update locks the same row, so a viral post serialises thousands of writes per second on one row, causing timeouts and slowing the whole shard. Batching deltas or sharding the counter removes that contention.

How do you guarantee a user can like a post only once?

Make the primary key of the likes table (post_id, user_id) and use a conditional insert, so a second like by the same user finds the row and changes nothing. Only an actual insert emits a +1 event.

What is a sharded counter?

A single logical counter split into N sub-counters; each increment goes to a random one and a read sums them. It spreads write load for very hot keys at the cost of a slightly more expensive read, which you can cache briefly.

How do you avoid losing or double-counting likes if a service crashes?

Feed the event log from the database with change data capture or an outbox so a committed like always produces an event, and have the aggregator store the last applied offset atomically with the count so replays are skipped.

Does the like count need strong consistency?

No. The public count can lag by a second or two as long as it converges exactly, but the user must see their own like immediately, which the app handles with an optimistic update and a liked_by_me lookup.

How do you serve counts for a feed page efficiently?

Use a batch endpoint that returns counts and liked_by_me for all posts on the page, backed by a single multi-get against a cache that the aggregator keeps updated for hot posts.

Which database would you use for the likes table?

A horizontally scalable wide-column or key-value store such as Cassandra or DynamoDB, partitioned by post_id with user_id as the sort key, because the data is huge, write-heavy and only accessed by key. A viral post makes its partition hot too, so watch per-partition limits and split very hot posts across a few partition keys if needed.

How do you detect drift between the counter and the real number of likes?

Run a reconciliation job that recounts likes for recently active posts and compares them with the stored counter, correcting differences and alerting, since any drift points to a bug in the fast path.