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.
- 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)
- 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.
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.
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.
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.
- 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
- 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
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.
- 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.
- 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.
- 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:
- 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.
- 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