TL;DR

1. The problem: why are likes and counters hard?

A like is the smallest action in Instagram — a double-tap. But it is performed billions of times a day, often concentrated on a handful of posts: when a global celebrity posts, tens of thousands of people may like it every second for the first minutes. Each like must:

Comments add text, threads, ranking and abuse. The core tension is that the naive model — one row per post with a like_count column — turns the most popular content into the most contended row in the database.

🧠 Analogy Imagine counting the crowd entering a stadium with one clicker held by one steward. Every fan must queue to get clicked — the steward is the bottleneck (a row lock). Better: put a steward with a clicker at each of 64 gates and add up the clickers at half-time (sharded counters). Or have each gate jot tallies on paper and radio the total every 10 seconds (stream aggregation and batching). The scoreboard shows "≈ 52,000 fans" (approximate count) — nobody needs it exact to the person, as long as each ticket (the like row) is only scanned once.

2. Requirements and write volume

2.1 Functional

2.2 Estimate (assumptions)

Daily active users            500M
Likes per user per day        10            → 5B likes/day   ≈ 58,000 likes/s avg, ~150k/s peak
Comments per user per day     1             → 500M/day       ≈ 5,800 comments/s avg
Posts viewed per user per day 200           → 100B post renders/day ≈ 1.2M/s
  each render reads: like count, comment count, "viewer liked?", 1–2 preview comments
Like row size                 ~30 B (post_id 8, user_id 8, ts 8, overhead)
Like storage                  5B × 30 B ≈ 150 GB/day ≈ 55 TB/year (before replication)

Viral post: 2M likes in the first 10 minutes  → ~3,300 likes/s on ONE post, spikes far higher

Public figures from around 2016 put Instagram at over 4 billion likes per day, consistent with this order of magnitude. Note the ratios: engagement reads outnumber writes ~20:1 overall, but the writes are what break naive designs because they concentrate on hot rows. See capacity estimation.

3. Data model

likes                PRIMARY KEY (post_id, user_id)          -- source of truth, one row per like
  post_id | user_id | created_at

user_likes           PRIMARY KEY (user_id, post_id)          -- reverse index: "what did I like?"
  user_id | post_id | created_at                                (same data, other partition key)

post_counters        post_id → like_count, comment_count       -- denormalised, eventually consistent
                     (or sharded: (post_id, shard) → partial_count)

comments             PRIMARY KEY (post_id, comment_id)        -- comment_id time-sortable
  post_id | comment_id | author_id | parent_id (NULL = top-level) | text | status | like_count | created_at

comment_likes        PRIMARY KEY (comment_id, user_id)

Instagram's diagram places post/comment metadata in a Cassandra-style store sharded by ID, with counters cached in Redis. Earlier, Instagram ran likes in sharded PostgreSQL — the same model applies to either.

4. The counter problem

4.1 Why count = count + 1 breaks

BEGIN;
INSERT INTO likes (post_id, user_id) VALUES (42, 1001);
UPDATE posts SET like_count = like_count + 1 WHERE post_id = 42;   -- takes a ROW LOCK on post 42
COMMIT;                                                               -- lock released only here

In a relational database, the UPDATE takes an exclusive row lock on post 42 until the transaction commits. Every other transaction liking post 42 must wait in line. If a commit takes ~1–2 ms (WAL flush, synchronous replication), that row can absorb at most ~500–1,000 increments per second — no matter how many servers you add, because they all queue for the same row.

Single counter row: everyone queuesSharded counter: writes spread out like tx 1 like tx 2 like tx 3 like tx … post 42 row🔒 like_count (locked) ≤ ~1,000 increments/s, queue grows like tx 1 like tx 2 like tx 3 like tx … (42, shard 0): 1,204 (42, shard 1): 1,187 (42, shard 2): 1,240 … shard 63 read = SUM(shards), cached throughput ≈ N × single row
Left: one hot row serialises every like. Right: each like increments a random shard row, so N rows absorb N times the write rate.

Other side effects make it worse: in PostgreSQL every UPDATE writes a new row version (MVCC), so a hot counter produces dead tuples that need vacuuming; replicas replay every one of those updates; and the same row is also being read by everyone viewing the post, so cache invalidation fires constantly.

5. Counter solutions

5.1 Sharded (distributed) counters

Split the counter into N sub-counters (post_id, shard). Each like increments a random (or hash(user_id) % N) shard; reading sums the shards (and caches the sum).

def increment(post_id):
    shard = random.randrange(N)               # N = 1 for normal posts, 64+ for hot ones
    db.execute("UPDATE post_counter_shards SET c = c + 1 WHERE post_id=%s AND shard=%s", post_id, shard)

def read(post_id):
    return cache.get_or_load(f"likes:{post_id}", ttl=5s,
        loader=lambda: db.scalar("SELECT SUM(c) FROM post_counter_shards WHERE post_id=%s", post_id))

Write throughput scales ~N×; reads cost N rows (hence caching). Google's Cloud Datastore/Firestore documentation recommends exactly this pattern. N can grow dynamically when a post gets hot.

5.2 Buffer and aggregate in a stream

Don't update the counter per like at all. The Like service writes the like row (the truth) and emits LikeCreated/LikeRemoved to Kafka, keyed by post_id. A counter-aggregator consumer sums deltas in memory for a short window and applies one batched update per post:

Like servicewrites like row Kafkalikes, key = post_id Counter aggregatorΣ deltas per post / 2 s Counter DBcount += 40,000 (one write) Redis counter cacheread by every post render 20k events/s 0.5 writes/s
20,000 like events per second on one viral post become one counter write every two seconds.

5.3 Redis INCR + periodic flush

Keep the live counter in Redis: INCR likes:42 is atomic, single-threaded and handles ~100k+ ops/s per shard. A background job periodically writes the values to durable storage. Fast and simple, but Redis is memory-first: a failover can lose recent increments (mitigated by AOF persistence and replicas), so run a reconciliation job that recounts from the like rows for drifted or hot posts. Many systems combine 5.2 and 5.3: the aggregator updates both the DB and the Redis cache.

5.4 Approximate counts

5.5 Comparison

ApproachWrite throughputRead costFreshnessDurability / accuracy
Single row UPDATE~1k/s per post1 rowImmediateExact; collapses under hot posts
Sharded counters~N × 1k/sN rows (cache it)ImmediateExact
Stream aggregationVery high (memory)1 row/cacheSeconds behindExact with offset-atomic commits; else reconcile
Redis INCR + flushVery high1 keyImmediate in RedisSmall loss possible on failover; reconcile
Cassandra countersHigh1 rowImmediateNot idempotent: a retried increment can double-count
Approximate (HLL, rounding)Very highTinyImmediateBounded error by design
💡 The golden rule The like rows are the source of truth; the count is a derived, cached, possibly-approximate view of them. If the count ever drifts, you can always recompute it from the rows.

6. Eventual consistency of displayed counts

With any of the scalable techniques, the number shown under a post is eventually consistent: it may lag the true count by a few seconds, and two viewers may briefly see slightly different numbers (different cache replicas, different refresh times). That's an acceptable trade-off (CAP & consistency) — with one important exception: the user's own action must be reflected immediately (read-your-writes).

7. Idempotency: a like counts once

Mobile clients retry, users double-tap twice, and Kafka delivers at least once. Correctness rests on two rules:

  1. The like row is idempotent by key. Inserting (post_id, user_id) twice leaves one row.
  2. Counter deltas come from state transitions, not from requests. Emit +1 only when a row was actually created, −1 only when a row was actually deleted.
def like(user_id, post_id):
    with db.transaction():
        created = db.execute("""INSERT INTO likes (post_id, user_id, created_at)
                                VALUES (%s, %s, now())
                                ON CONFLICT (post_id, user_id) DO NOTHING
                                RETURNING 1""", post_id, user_id)
        if created:                                    # state changed: not-liked → liked
            outbox.add("LikeCreated", post_id=post_id, user_id=user_id, delta=+1)
    return {"liked": True}                             # same response for retries

def unlike(user_id, post_id):
    with db.transaction():
        deleted = db.execute("DELETE FROM likes WHERE post_id=%s AND user_id=%s RETURNING 1",
                             post_id, user_id)
        if deleted:                                    # liked → not-liked
            outbox.add("LikeRemoved", post_id=post_id, user_id=user_id, delta=-1)
    return {"liked": False}
🚨 The classic bug INSERT like; INCR counter on every request, without checking whether the insert created a row. Retries then inflate counts, and an unlike after a retried like can drive the count negative. Always derive deltas from actual state changes.

8. "Has the viewer liked this?" for every post in the feed

Each feed page shows ~10–20 posts, and every one needs a filled or empty heart. At ~1.2M post renders/s, doing one query per post would be ~1.2M point lookups/s with 20× the round trips. The fix is batching plus caching:

  1. One batched lookup per page against the user-partitioned index: SELECT post_id FROM user_likes WHERE user_id = :viewer AND post_id IN (:p1 … :p20) — one partition, one round trip. (Or a multi-get of 20 (post_id, viewer) keys on the post-partitioned table, issued in parallel.)
  2. Cache the viewer's recent likes: most feed posts are recent, so a per-user cache of liked post IDs from the last ~30 days (a Redis set) answers most lookups in memory, updated on every like/unlike.
  3. Exploit that most answers are "no": users like a small fraction of what they see. A per-user Bloom filter of liked post IDs answers "definitely not liked" without touching storage; only "maybe" answers go to the cache/DB.
  4. Assemble in parallel with other per-post data (counts, author profile, preview comments) when the feed service hydrates the page.

Facebook's TAO exposes exactly this shape of query — assoc_get(viewer, LIKES, [post ids]) — answered from its cache tier; see Social graph.

Feed page (20 posts) hydration — all in parallel, one round trip each:
  ├─ posts metadata          multi-get 20 post_ids             (cache → Cassandra)
  ├─ like / comment counts   multi-get 20 counters             (Redis)
  ├─ viewer liked?           user_likes(viewer) ∩ {20 ids}     (Bloom → Redis set → DB)
  ├─ authors                 multi-get ~15 user_ids            (cache → User DB)
  └─ preview comments        top-2 comments per post           (precomputed cache)

9. Comments: pagination, ranking and threading

9.1 Pagination

A popular post can have hundreds of thousands of comments; the app loads ~20 at a time. Use cursor (keyset) pagination, never OFFSET:

-- first page (newest first)
SELECT * FROM comments WHERE post_id = 42 AND parent_id IS NULL ORDER BY comment_id DESC LIMIT 20;
-- next page: cursor = last comment_id seen
SELECT * FROM comments WHERE post_id = 42 AND parent_id IS NULL AND comment_id < :cursor
ORDER BY comment_id DESC LIMIT 20;

OFFSET 100000 forces the database to walk and discard 100,000 rows; the cursor version seeks directly via the clustering key. Cursors are also stable when new comments arrive (no duplicates or skips). See API design.

9.2 Ranking: "top comments"

Chronological order on a celebrity post shows mostly spam and emoji. Instagram ranks comments, typically considering:

Implementation: a background job (triggered by comment/like events) maintains a precomputed top-K list per post (e.g. 200 comment IDs with scores) in cache; at read time the service lightly re-ranks it with viewer-specific boosts. The first page is ranked; deeper pages can fall back to chronological cursors.

9.3 Threading

Threads are modelled with parent_id. Instagram uses a shallow, two-level structure: top-level comments, and replies to a top-level comment (a reply to a reply attaches to the same top-level parent with an @mention). This keeps queries simple:

top-level:  WHERE post_id = 42 AND parent_id IS NULL          (ranked / paginated)
replies:    WHERE post_id = 42 AND parent_id = :c ORDER BY comment_id ASC LIMIT 3   ("View 41 more replies")

10. Moderation and spam filtering

Comments are a major abuse vector (spam links, bots, harassment). Defence happens in layers:

LayerWhenExamples
Permission checksSynchronous, before writeCommenter blocked by author? Comments turned off? Author limits comments to followers? Rate limits per user/IP/device
Author filtersSynchronousHidden-words / keyword lists; "Restrict" (restricted user's comments are visible only to themselves unless approved)
ML classifiersSync (fast model) + async (heavier models)Spam, bullying and offensive-comment classifiers (Instagram publicly launched an automatic offensive-comment filter in 2017); duplicate-text detection across many posts (SimHash); link reputation
Account signalsAsyncAccount age, velocity, network of fake accounts
Human reviewAsyncUser reports, appeals

A comment therefore has a status (VISIBLE, PENDING, HIDDEN_BY_FILTER, RESTRICTED, DELETED). Hidden comments are often still visible to their own author ("shadow" visibility), which avoids tipping off spammers. Counts usually include only visible comments, which is another reason counts are maintained by a stream that sees status changes.

11. Emitting events for notifications and more

Every like, unlike and comment is published (via the outbox) to Kafka. Consumers include:

{ "event_id": "evt-7c1e...", "type": "LikeCreated", "post_id": 42, "post_author_id": 7,
  "liker_id": 1001, "ts": "2026-10-23T09:20:11Z", "delta": 1 }

Including post_author_id lets the notification service act without looking the post up.

12. Viral posts and hot keys

A celebrity post creates hot spots on both the write and read paths:

Hot spotSymptomMitigation
Counter rowLock contention, replication lagStream aggregation, sharded counters
Likes partition (post_id, …)One partition gets tens of thousands of inserts/s and grows to tens of millions of rowsBucket the key: (post_id, hash(user_id) % N) for hot posts; the user-partitioned index is naturally spread
Cache key for count / postMillions of reads/s hit one cache serverReplicate hot keys across servers (likes:42#r7), short-TTL in-process caches on app servers, request coalescing
Cache invalidation stampedeEach update invalidates the key; thousands of readers miss and hit the DB at onceDon't invalidate per like — refresh on a timer; use leases / single-flight so one request refills
Comment listFlood of comments and reads on one postPrecomputed top-K in cache; rate limits; ranking hides the flood

Facebook's Scaling Memcache paper (NSDI 2013) describes leases for exactly the stampede problem, and Instagram engineers have publicly told the story of a celebrity's post whose likes caused cache-miss storms and expensive like-count queries — fixed by denormalising the count and using memcache leases so only one request recomputes it. See Caching.

13. Hiding like counts

Instagram tested hiding public like counts in several countries in 2019 and in 2021 gave everyone the option to hide like counts on others' posts and on their own posts. System implications:

14. How Instagram uses all this

📱 Double-tapoptimistic ❤️
→
Like & Comment serviceinsert row + outbox
→LikeCreated
Kafkakey = post_id
→
Aggregator / notifications / ML
→
Counters (Redis + DB)
📸 In Instagram In the diagram, the Like & Comment Service writes like and comment rows to the post/comment metadata store, keeps hot counters in the Redis feed cache, and publishes like/comment events to Kafka, consumed by the Notification Service and Analytics & ML. Public engineering talks (e.g. "Scaling Instagram Infrastructure", QCon 2017) describe the practical lessons: like counts are denormalised rather than computed with COUNT(*), and cache refills for very popular posts are protected with memcache leases so a celebrity post doesn't trigger a thundering herd on the database. With public figures of several billion likes per day, the techniques in this lesson are not optional at Instagram's scale.

15. Trade-offs & common pitfalls

✅ This design gives you

  • Write throughput independent of post popularity
  • Exactly-once semantics for the like itself (composite key)
  • Cheap reads: one cached counter, one batched "liked?" lookup per page
  • Decoupled side effects (notifications, ranking, analytics)

⚠️ At the cost of

  • Counts that lag by seconds and differ slightly between viewers
  • Dual indexes (post- and user-keyed) to keep in sync
  • More moving parts: stream jobs, reconciliation, cache replication
  • Approximate numbers in some views

Common pitfalls

16. Real-world technologies

TechnologyWhen to use it
PostgreSQL / MySQL (sharded) with ON CONFLICT/INSERT IGNORELike rows with composite keys and transactional outbox; moderate scale per shard
Cassandra / ScyllaDBVery high write rates for like and comment rows partitioned by post or user
Redis (INCR, sets, HyperLogLog)Live counters, per-user liked-set caches, approximate uniques
Memcached with leasesRead-heavy caching of counts and comment lists with stampede protection
Apache Kafka + Flink / Kafka Streams / Spark StreamingAggregating like deltas, real-time engagement features
Facebook TAO (paper) / graph-style association storesLikes as associations with counts and per-viewer lookups at massive scale
Firestore / Datastore sharded countersManaged-cloud implementation of distributed counters
Perspective-style toxicity APIs, in-house classifiersComment moderation and spam detection

17. Interview corner

How would you store likes, and how do you make sure a user can't like a post twice?

One row per like with primary key (post_id, user_id) in a store sharded by post_id, plus a reverse table keyed by (user_id, post_id) for "what did I like / did I like these". The composite key makes the like idempotent: a retried or double-tapped like is an insert that does nothing. Only when an insert actually creates a row do we emit LikeCreated (+1); only when a delete actually removes a row do we emit LikeRemoved (−1). The count is derived from those transitions.

A celebrity posts and gets 20,000 likes per second. How do you keep the like count working?

Don't update a single row per like — its row lock caps throughput around a thousand per second. Write the like rows (spread by a bucketed key for hot posts), publish events to Kafka keyed by post_id, and have an aggregator sum deltas in memory and apply one update every second or two (offsets committed atomically with the delta, or periodic reconciliation). Serve the count from a replicated hot cache key with short TTLs and no per-like invalidation; display it rounded. Alternatives: sharded counter rows (N sub-counters summed on read) or Redis INCR with periodic flush.

How do you show whether the viewer liked each of the 20 posts in their feed?

One batched query per page against the user-partitioned index (user_id = viewer AND post_id IN (…20…)), executed in parallel with the other hydration calls. Back it with a per-user cache of recently liked post IDs (Redis set), and optionally a per-user Bloom filter, since the vast majority of answers are "not liked". The viewer's own writes update that cache synchronously, giving read-your-writes even though public counts are eventually consistent.

How would you paginate and rank comments on a post with 500,000 comments?

Comments are partitioned by post_id and clustered by a time-sortable comment_id, with parent_id for two-level threads. Pagination uses cursors (comment_id < :cursor LIMIT 20), never OFFSET. The first page shows "top comments": a background job maintains a scored top-K list per post (likes and replies with time decay, author quality, integrity scores, pinned comments), refreshed on engagement events; at read time it's re-ranked with viewer-specific boosts (comments from people the viewer follows). Replies are loaded lazily per parent with a stored reply_count.

Why are displayed like counts eventually consistent, and is that acceptable?

Because the count is a derived value updated asynchronously (stream aggregation, cache refresh) to avoid write contention. Users can't perceive a few seconds of lag or a difference of a few likes on large numbers, and the UI rounds large numbers anyway. What must be consistent is the viewer's own action: optimistic UI plus the user's own like row/cached liked-set guarantee read-your-writes. Periodic reconciliation from like rows corrects any drift.

How do likes turn into notifications without spamming the author?

LikeCreated events go to the notification service via Kafka. It holds them briefly (so a quick unlike cancels), aggregates per (author, post) into "X and N others liked your post", respects user notification preferences, rate-limits per recipient, and for very large accounts stops sending per-like pushes entirely (showing aggregates in the activity feed instead). Deduplication on event_id handles redelivery.

What changes in the system when users can hide like counts?

Nothing in storage or counting — counts are still maintained for the author, ranking and analytics. The read path applies the viewer's preference and the post's hide flag server-side, omitting the number from the API response and showing "Liked by alice and others" instead. Doing it server-side prevents trivially reading hidden counts from API responses.

18. Check your understanding

A counter row's update transaction holds its lock for 2 ms. What's the maximum like rate for that post, and how does a 64-way sharded counter change it?

1 / 0.002 s = 500 updates/s for one row. With 64 sub-counter rows, roughly 64 × 500 = 32,000/s (if the shards live on rows/nodes that don't contend), at the cost of summing 64 rows on read — which you cache.

A user taps like, the response times out, the app retries, then the user unlikes. With "INSERT like; INCR count" on every request (no transition check), what is the final count delta? With the transition-based design?

Naive: +1 (first) +1 (retry) −1 (unlike) = +1, but the user doesn't like the post — the count is wrong by one. Transition-based: +1 (row created), 0 (retry inserted nothing), −1 (row deleted) = 0, matching reality.

Why is OFFSET 50000 LIMIT 20 bad for comment pagination, and what's the alternative?

The database must read and discard 50,000 rows to return 20, so deep pages get slower and costlier; results also shift as new comments arrive. Keyset/cursor pagination (WHERE comment_id < :last_seen ORDER BY comment_id DESC LIMIT 20) seeks straight to the position and is stable.

With the volume assumptions above, how many like events per second hit Kafka on average, and roughly how many counter writes per second does a 2-second aggregation window produce?

5B/day ÷ 86,400 ≈ 58,000 events/s. Counter writes become ≈ (number of distinct posts liked in each 2-second window) ÷ 2 — far fewer than 58k/s, because popular posts collapse thousands of events into one write. For a single viral post at 20k likes/s: 0.5 writes/s.

Why are Cassandra counter columns risky for likes?

Counter increments aren't idempotent: if a write times out and the client retries, the increment may be applied twice, and there's no way to tell. Counts drift upwards. Use idempotent like rows as truth and derive counts, or reconcile periodically.

Scenario: a viral post's cached like count is invalidated on every like. What happens, and what's the fix?

Each invalidation makes thousands of concurrent readers miss and recompute/fetch the count from the database at once — a cache stampede that can overload the DB. Instead, refresh the cached value on a short timer (or from the aggregator) without per-like invalidation, use leases/single-flight so only one request refills, and replicate the hot key across cache servers.

Why might a per-user Bloom filter of liked posts be a good fit for the "viewer liked?" check, when it's risky for story seen-state?

Here a "maybe liked" answer is verified against the cache/DB before showing a filled heart, so false positives only cost an extra lookup; "definitely not liked" (the common case) skips storage entirely. In story seen-state, a false positive was shown directly to the user as wrong UI state.

🎯 Key takeaways

Related lessons