Archtin
All articles
System DesignScalabilityDatabasesInterview13 min read

How Instagram Handles 1 Billion Likes

Designing a like system at billion-scale: why COUNT(*) is impossible, how counters are sharded and aggregated asynchronously, idempotent like writes, hot-post handling, and the consistency trade-off you must name.

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 immediate
A workable back-of-the-envelope

That 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;
What everyone writes first

Three independent things kill this:

  1. 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.
  2. 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.
  3. 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.

QuestionRepresentationConsistency
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, cachedEventually consistent, seconds
Who liked P?Time-ordered list, paginated, cappedEventually 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 showed
Write path

Step 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;
Sharded counter

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 feed
Counting via a stream, with the cache as the serving layer

Redis 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
End to end
  • Partition by post_id so 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.

ProblemMitigation
Millions of writes to one post_id partitionIn-memory windowed aggregation; more counter shards for hot posts
Millions of reads of one count keyLocal in-process cache with a 1s TTL in front of Redis; read replicas of the cache
Notification fan-out to the authorCollapse into digests — never one push per like
Liker list grows unboundedCap the materialised list (e.g. latest 1,000) and paginate the rest from storage
Count precision at huge numbersRound in the UI — '2.4M' hides staleness entirely and users prefer it
The cheapest trick in the design
Displaying "2.4M likes" instead of "2,413,882" means your count can be tens of thousands off and nobody can tell. Rounding at display time buys you enormous freedom in the backend. Use it deliberately, and say so in an interview.

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."
A strong four-minute answer

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.

Part of Top 10 System Design Interview Questions.

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free