A like is one row, two columns and a button. It is also one of the best scale questions in the interview canon, because it combines every hard property at once: enormous write volume, enormous read fan-out, severe hotspots on popular posts, a count that must render instantly, and a correctness requirement that is much weaker than it first appears.
Why a like is a hard problem
- Writes are unbatched and user-initiated. Hundreds of thousands per second globally, spiky, and concentrated on whatever is trending right now.
- Reads dwarf writes. Every feed render needs a count and a "did I like this" flag for every visible post. One scroll is dozens of these.
- The distribution is brutally skewed. Most posts get a handful of likes; a few get millions in minutes. A design that works on average fails on the tail — and the tail is the part everybody looks at.
- Likes must be idempotent. Double taps, retries after timeouts and offline queues must not produce two likes from one user.
Requirements and numbers
Functional:
- Like and unlike a post; the action feels instant.
- Show the like count on every post.
- Show whether the current viewer has liked it.
- List some likers ("liked by X and 4,201 others").
500M DAU, ~10 likes per user per day → 5B likes/day
5,000,000,000 / 86,400 ≈ 58,000 likes/sec average
peak 3x ≈ 175,000 likes/sec
Feed reads: 500M users × 200 posts seen = 100B count reads/day
≈ 1.2M count reads/sec average
Storage per like: post_id 8B + user_id 8B + ts 8B + overhead ≈ 50B
5B/day × 50B ≈ 250 GB/day of raw likes
Latency targets: like ack < 100 ms, count render < 10 ms (cache)
Consistency: counts may be seconds stale; "did I like" must be immediateThat last line is the whole design. Counts are allowed to be wrong for a few seconds; the viewer's own like state is not. Different consistency requirements for different fields of the same object means they belong on different paths.
The naive design and where it dies
-- likes(post_id, user_id, created_at), PK (post_id, user_id) INSERT INTO likes (post_id, user_id) VALUES ($1, $2); -- and then, on every feed render, for every post: SELECT COUNT(*) FROM likes WHERE post_id = $1;
Three independent things kill this:
COUNT(*)scans. Counting 4 million rows to render a number takes hundreds of milliseconds and reads gigabytes. Multiply by 1.2M reads/sec and it is not a slow system, it is an impossible one.- A single counter row is a write hotspot. The obvious fix —
UPDATE posts SET like_count = like_count + 1— serialises every like on a trending post behind one row lock. At 20,000 likes/sec on one post, that row becomes a queue and lock waits cascade into your connection pool. - Synchronous writes tie the user's tap to your database's worst moment. A like should acknowledge in tens of milliseconds regardless of what the storage tier is doing.
Split the write path from the count
The core insight: a like is three different data problems, and each deserves its own representation.
| Question | Representation | Consistency |
|---|---|---|
| Did user U like post P? | Membership record keyed by (post_id, user_id) | Strong for that user, read-your-writes |
| How many likes does P have? | Maintained aggregate, cached | Eventually consistent, seconds |
| Who liked P? | Time-ordered list, paginated, capped | Eventually consistent, best-effort |
The write path becomes:
POST /posts/:id/likes (Idempotency-Key: <client uuid>)
1. Write the membership row, idempotently:
INSERT INTO likes (post_id, user_id, created_at)
VALUES ($1, $2, now())
ON CONFLICT (post_id, user_id) DO NOTHING;
-> rows_affected tells you whether this was a NEW like
2. If new: update the viewer's own cached state immediately
(so their UI is correct on the next read)
3. If new: emit an event {post_id, user_id, delta:+1, ts}
to the counter pipeline — asynchronously
4. Return 200 with the optimistic count the client already showedStep 1 gives idempotency for free: the primary key does the deduplication, so double taps, retries and redelivered queue messages all collapse to one like. That is exactly the mechanism described in the idempotency pattern, and it is the same reason it prevents duplicate payments.
Step 3 is the part that makes it scale: the durable like is committed, and the count catches up. An unlike is the mirror image — DELETE and emit delta: -1.
Making counters survive hot posts
Two techniques, usually combined.
Sharded counters
Instead of one row per post, keep N rows and sum them on read. Writes spread across N locks, so contention drops by roughly N.
like_counts(post_id, shard_id, count) PK (post_id, shard_id) -- write: pick a shard from the liker's id so a user always hits one shard UPDATE like_counts SET count = count + 1 WHERE post_id = $1 AND shard_id = hash(user_id) % 32; -- read (rare — the cached value serves almost all traffic) SELECT sum(count) FROM like_counts WHERE post_id = $1;
Batched aggregation from a stream
Better at the very top end: do not update per like at all. Publish like events to a log (Kafka), partition by post_id, and have a consumer aggregate in memory over a one-second window, then apply one +2,143 increment instead of 2,143 increments. This converts write amplification into a fixed, predictable rate and is why a log-based pipeline is the standard backbone for this kind of counting.
likes topic (partitioned by post_id)
→ consumer aggregates 1s windows in memory
→ INCRBY like_count:{post_id} <delta> in Redis (serving value)
→ periodic flush of the authoritative total to durable storage
Feed read path:
MGET like_count:{p1} like_count:{p2} ... like_count:{p50}
one round trip, sub-millisecond, for the entire visible feedRedis holds the value that users actually see. Durable storage holds the value you rebuild from after a cache loss — and because likes are all still in the membership table, you can always recount offline to repair drift.
Did I like this post?
This is a membership test for one user across the ~50 posts on screen, and it must be right for that user immediately, otherwise the heart flickers back to grey and it looks broken.
- Client-side optimism first. The tap flips the heart locally before any network call. The server call only confirms.
- A per-user cache of recent likes. A Redis set per user of recently liked post IDs answers the whole visible feed in one round trip, and is written synchronously on like so the user's own next read is correct.
- Bulk lookup, never per post. One query with
post_id IN (...)against(user_id, post_id)— note the index order, it is the reverse of the like table's primary key, so you need a second index. - Bloom filter for the negative case at extreme scale: most users have not liked most posts, and a filter answers "definitely not" without touching storage.
The asynchronous aggregation pipeline
The full shape, and the properties that make it safe:
Client ──▶ API ──▶ likes table (durable, idempotent, source of truth)
│
└─▶ Kafka: likes topic (partition by post_id)
│
├─▶ counter consumer → Redis counts (serving)
├─▶ notification service ("X liked your post")
├─▶ ranking/ML features (engagement signals)
└─▶ analytics warehouse
Feed render ──▶ Redis MGET counts + user's liked-set ──▶ response- Partition by
post_idso all events for a post are ordered and handled by one consumer — that is what lets you aggregate in memory safely. It also means a viral post creates a hot partition, which is the trade-off discussed in Kafka consumer lag. - Publish reliably. If the row commits and the event is lost, the count drifts permanently. Use the outbox pattern so the event is committed in the same transaction as the like.
- Make consumers idempotent. At-least-once delivery means the same event may be processed twice; dedupe on
(post_id, user_id, delta)or apply deltas from a deduplicated log rather than blindly incrementing. - Reconcile. A nightly job recounts from the membership table and corrects the serving counter. Small drift is invisible; unbounded drift is a bug.
Celebrity posts and hot keys
A post from a huge account breaks the average-case design in two directions at once, and both need explicit handling.
| Problem | Mitigation |
|---|---|
| Millions of writes to one post_id partition | In-memory windowed aggregation; more counter shards for hot posts |
| Millions of reads of one count key | Local in-process cache with a 1s TTL in front of Redis; read replicas of the cache |
| Notification fan-out to the author | Collapse into digests — never one push per like |
| Liker list grows unbounded | Cap the materialised list (e.g. latest 1,000) and paginate the rest from storage |
| Count precision at huge numbers | Round in the UI — '2.4M' hides staleness entirely and users prefer it |
The consistency trade-off
Name it explicitly, because this is what the question is really probing. Three different guarantees coexist:
- Strong, for the acting user: your own like is durable before you get a 200, and your own subsequent reads reflect it.
- Eventual, for the global count: bounded staleness of a few seconds, plus small transient drift, repaired by reconciliation.
- Best-effort, for the liker list and notifications: collapsed, sampled, capped.
The reason this is acceptable is a product fact, not a technical one: nobody can distinguish 1,204,881 likes from 1,204,903, but everybody notices a feed that takes two seconds to paint. Spending correctness where it is invisible to buy latency where it is visible is the trade. Notice that the identical architecture would be unacceptable for account balances — which is exactly why payments use strict idempotency and reconciliation instead.
How to answer this in an interview
"First the numbers: ~5B likes/day is roughly 58k writes/sec, but count reads
are ~1.2M/sec, so this is a read-optimisation problem with a write hotspot.
A like is really three questions with three different consistency needs, so
I'd store them separately:
- membership row keyed (post_id, user_id) — primary key gives idempotency
for double taps and retries, and answers 'did I like this'
- the count as a maintained aggregate, never COUNT(*)
- the liker list capped and paginated
The write commits the row, then emits an event via an outbox. A consumer
partitioned by post_id aggregates one-second windows in memory and applies a
single INCRBY to Redis, which is what the feed reads with one MGET.
A single counter row would serialise every like on a viral post, so either
shard the counter or aggregate from the stream — I'd do the stream, and add a
1-second local cache for hot posts on the read side.
The trade-off I'm accepting: counts are seconds-stale and can drift slightly,
repaired by a nightly recount from the membership table. That's fine for
likes — and the UI rounds to '2.4M' anyway — but I would not accept it for
money."Likely follow-ups: how do you handle unlike races (last-write-wins on the membership row, with the delta derived from whether the row actually changed); what if Redis loses everything (rebuild from durable counts, recount from membership in the background); how would you rank the feed by engagement (the same stream feeds ranking features).
Summary
- Never
COUNT(*)a like table; keep the count as a maintained aggregate. - A single counter row is a write hotspot — shard it or aggregate from a partitioned stream.
- The membership row's primary key gives you idempotency for double taps and retries.
- "Did I like this" is per-user, strongly consistent, and answered in bulk from a per-user cache.
- Publish via an outbox and make consumers idempotent, or counts drift permanently.
- Reconcile nightly, round in the UI, and state the staleness you are accepting.