- Likes and comments are Instagram's highest-volume writes: billions per day. Store each like as a row keyed by
(post_id, user_id)— that row is the source of truth and makes likes naturally idempotent. - The naive counter
UPDATE posts SET likes = likes + 1serialises on a row lock; a viral post saturates it. Use sharded counters, stream aggregation (Kafka → batched deltas), or Redis INCR + periodic flush, and display approximate counts. - Displayed counts are eventually consistent; the viewer's own like is shown instantly via optimistic UI and their own like row.
- "Has the viewer liked each of these 20 posts?" must be one batched lookup (plus caching), not 20 queries.
- Comments need cursor pagination, ranking (top comments), shallow threading via
parent_id, and moderation. Engagement events feed notifications, ranking and analytics; viral posts need hot-key protection.
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:
- be recorded exactly once per user (double-tapping twice isn't two likes),
- update a visible counter that millions of people are reading at the same time,
- tell the author (notification), feed ranking and analytics,
- and be reflected in the "❤️ filled" state for that user on every device.
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.
2. Requirements and write volume
2.1 Functional
- Like / unlike a post (and a comment); show like count and "liked by X and others".
- Show whether the current viewer has liked each post they see.
- Add, delete, like and reply to comments; show comment count, top comments and threads.
- Notify the author; let users hide like counts; filter offensive comments.
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)
- Composite key
(post_id, user_id)guarantees at most one like per user per post — the database enforces idempotency for free. - Two access paths: "who liked post P" (partition by post) and "has user U liked these posts / what has U liked" (partition by user). In a sharded store each needs its own table (dual writes via the outbox or a CDC pipeline), or a TAO-style association store that keeps both directions (see Social graph).
- Counters are denormalised: never compute
SELECT COUNT(*) FROM likes WHERE post_id = ?on the read path — for a post with 10M likes that scans 10M rows. - Comments are partitioned by post so "comments of P, newest first" is a single-partition range scan;
parent_idimplements threading (section 9).
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.
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:
- Massive write reduction: 20k likes/s on one post → one
count += 40,000every 2 s. - Exactly-once-ish: commit the consumer offset and the counter delta atomically (store the last applied offset per partition in the same DB transaction), or accept tiny drift and reconcile periodically.
- Ordering: keying by post_id puts all of a post's like/unlike events on one partition, in order.
- Lag: counts trail reality by the window plus consumer lag (seconds) — acceptable for a display count.
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
- Display rounding: the UI shows "1.2M likes". Being off by a few hundred is invisible; only small numbers need to be exact.
- HyperLogLog: estimates unique counts (e.g. unique viewers) in ~12 KB with ~0.8% error, regardless of cardinality.
- Sampling / probabilistic counters (e.g. Morris counters) for analytics where exactness doesn't matter.
5.5 Comparison
| Approach | Write throughput | Read cost | Freshness | Durability / accuracy |
|---|---|---|---|---|
| Single row UPDATE | ~1k/s per post | 1 row | Immediate | Exact; collapses under hot posts |
| Sharded counters | ~N × 1k/s | N rows (cache it) | Immediate | Exact |
| Stream aggregation | Very high (memory) | 1 row/cache | Seconds behind | Exact with offset-atomic commits; else reconcile |
| Redis INCR + flush | Very high | 1 key | Immediate in Redis | Small loss possible on failover; reconcile |
| Cassandra counters | High | 1 row | Immediate | Not idempotent: a retried increment can double-count |
| Approximate (HLL, rounding) | Very high | Tiny | Immediate | Bounded error by design |
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).
- The app uses optimistic UI (Clients): on double-tap it fills the heart and shows
count + 1locally before the server responds, rolling back only if the request fails. - The "viewer has liked" flag comes from the viewer's own like row (or a per-user cache updated synchronously on write), not from the lagging counter — so refreshing the feed still shows the heart filled.
- The author's own view of their counts can be refreshed more aggressively than everyone else's.
7. Idempotency: a like counts once
Mobile clients retry, users double-tap twice, and Kafka delivers at least once. Correctness rests on two rules:
- The like row is idempotent by key. Inserting
(post_id, user_id)twice leaves one row. - 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}
- Retries and double-taps: the second insert does nothing and emits nothing → counted once.
- Like → unlike → like rapidly: each real transition emits ±1, so the net delta is +1, matching the final state. The client also debounces (sends only the final state after ~300 ms of inactivity) to avoid churn.
- Ordering: events are keyed by post_id, so a post's events stay in order; even if processed out of order, the sum of ±1 deltas is the same.
- In stores without conditional inserts (e.g. Cassandra upserts), check the user's like state first (read-before-write against a cache) or use a lightweight transaction — or let the aggregator compute counts from per-(post,user) state instead of blind increments.
- Consumer idempotency: the aggregator dedupes on event ID or commits offsets atomically with deltas.
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:
- 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.) - 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.
- 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.
- 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:
- Engagement on the comment: likes, replies — with time decay so new good comments can rise.
- Relationship to the viewer: comments from people you follow or interact with are boosted (personalisation).
- Author signals: the post owner's replies, verified accounts, account quality.
- Integrity classifiers: likely spam, bullying or low-quality comments are demoted or hidden.
- Pinned comments: the post owner can pin a few to the top.
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")
- Store
reply_counton the parent (a counter maintained like like_count) to render "View N more replies" without counting. - Arbitrarily deep trees (Reddit-style) need materialised paths (
path = '0001.0007.0003') or closure tables; Instagram's shallow model avoids that complexity. - Deleting a parent shows "comment deleted" placeholders or hides the thread, depending on policy.
10. Moderation and spam filtering
Comments are a major abuse vector (spam links, bots, harassment). Defence happens in layers:
| Layer | When | Examples |
|---|---|---|
| Permission checks | Synchronous, before write | Commenter blocked by author? Comments turned off? Author limits comments to followers? Rate limits per user/IP/device |
| Author filters | Synchronous | Hidden-words / keyword lists; "Restrict" (restricted user's comments are visible only to themselves unless approved) |
| ML classifiers | Sync (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 signals | Async | Account age, velocity, network of fake accounts |
| Human review | Async | User 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:
- Notification service: "alice liked your post". It aggregates ("alice and 23 others liked your post"), waits a few seconds so a quick unlike cancels the notification, rate-limits per recipient, and skips per-like notifications entirely for accounts that receive thousands per minute.
- Counter aggregator (section 5.2).
- Feed and Explore ranking: engagement is a key signal; real-time features ("this post is getting likes fast") come from the stream (Analytics & ML).
- Integrity systems: detecting like-farms and bot rings by unusual patterns.
- Analytics / data warehouse: creator insights, A/B test metrics.
{ "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 spot | Symptom | Mitigation |
|---|---|---|
| Counter row | Lock contention, replication lag | Stream aggregation, sharded counters |
Likes partition (post_id, …) | One partition gets tens of thousands of inserts/s and grows to tens of millions of rows | Bucket the key: (post_id, hash(user_id) % N) for hot posts; the user-partitioned index is naturally spread |
| Cache key for count / post | Millions of reads/s hit one cache server | Replicate hot keys across servers (likes:42#r7), short-TTL in-process caches on app servers, request coalescing |
| Cache invalidation stampede | Each update invalidates the key; thousands of readers miss and hit the DB at once | Don't invalidate per like — refresh on a timer; use leases / single-flight so one request refills |
| Comment list | Flood of comments and reads on one post | Precomputed 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:
- Counts are still computed — the author sees them, ranking uses them, analytics needs them. Hiding is purely a presentation decision.
- The decision depends on two settings: the viewer's "hide counts on posts I see" preference and the author's per-post "hide like count" flag. The server applies them when building the response (omit the number, return "Liked by alice and others"), rather than sending the number and asking the client to hide it — otherwise anyone could read counts from the API response.
- The setting must be cheap to evaluate per post render: the viewer preference is loaded once per request; the per-post flag lives in post metadata.
14. How Instagram uses all this
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.- Truth: like rows keyed by (post, user), mirrored in a user-keyed index for "viewer liked?" lookups.
- Counts: derived, aggregated asynchronously, cached, shown rounded ("1.2M"), hidden per user/post settings.
- Comments: partitioned by post, cursor-paginated, top comments precomputed and personalised, two-level threads, layered moderation.
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
COUNT(*)on the read path for like or comment counts.- Incrementing on every request instead of on state transitions → inflated or negative counts.
- Cassandra counters with retries → double counting (counter updates aren't idempotent).
- N+1 queries for "viewer liked?" across a feed page.
- OFFSET pagination for comments on huge posts.
- Invalidating the cached count on every like of a viral post → cache stampede.
- Hiding counts only in the client while the API still returns them.
- Notification spam: one push per like for a popular creator.
16. Real-world technologies
| Technology | When to use it |
|---|---|
PostgreSQL / MySQL (sharded) with ON CONFLICT/INSERT IGNORE | Like rows with composite keys and transactional outbox; moderate scale per shard |
| Cassandra / ScyllaDB | Very 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 leases | Read-heavy caching of counts and comment lists with stampede protection |
| Apache Kafka + Flink / Kafka Streams / Spark Streaming | Aggregating like deltas, real-time engagement features |
| Facebook TAO (paper) / graph-style association stores | Likes as associations with counts and per-viewer lookups at massive scale |
| Firestore / Datastore sharded counters | Managed-cloud implementation of distributed counters |
| Perspective-style toxicity APIs, in-house classifiers | Comment 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.
- Like rows keyed by (post_id, user_id) are the truth; counts are derived, cached and may be approximate.
- Avoid single-row counters on hot posts: use sharded counters, stream aggregation or Redis INCR + flush, plus reconciliation.
- Make likes idempotent: emit ±1 only on real state transitions; debounce on the client.
- Answer "viewer liked?" with one batched lookup per page backed by per-user caches/Bloom filters.
- Comments: cursor pagination, precomputed top-K ranking, shallow parent_id threads, layered moderation.
- Publish engagement events for notifications, ranking and analytics; protect viral posts from hot keys and stampedes; enforce hidden like counts server-side.
Related lessons
- Caching — hot keys, stampedes, leases and Redis data structures
- Message Queues & Kafka — the event stream behind counters and notifications
- Sharding — partition keys and hot partitions
- Notifications — aggregating "X and others liked your post"
- News Feed — where like state and counts are hydrated per post
- CAP & Consistency — eventual consistency and read-your-writes
- API Design — idempotency and cursor pagination
- Stories — counting views with the same techniques
- Security — abuse and spam prevention