Designing Like and Reaction Systems
Likes and reactions (Love, Haha, Wow) generate the highest volume of write traffic on a social network. Modeling this data efficiently is vital for both fast ingestion and fast display.
Denormalization is Key
When rendering a feed, you need to know two things: the total reaction count, and whether the viewing user has reacted. A normalized SQL approach (checking a Reactions table for every post) is too slow.
Instead, the Posts table itself stores denormalized counters: like_count, love_count, etc. When a reaction occurs, these counters are incremented. The user's specific reaction is stored as a durable (post, user) record, which is the source of truth from which counters are derived; a fast key-value cache of it lets the UI highlight the correct button.
Handling High Write Throughput
For viral posts, incrementing a counter in a database causes severe lock contention (Hot Key Problem). If a million people like a post in a minute, updating that single row directly degrades into lock contention, timeouts and failed writes.
To solve this, systems use asynchronous buffering. Reactions are dropped into a Kafka queue. A background worker aggregates the counts (e.g., "+500 likes in the last second") and performs a single bulk update to the database. Alternatively, Redis can handle high-throughput atomic increments (INCR) and sync to the persistent database periodically. Either way, increments must be idempotent and deduplicated (count only state transitions of the (post, user) record), otherwise retries and replays double-count.
Worked example: a viral post
One million likes arrive in 60 seconds: about 16,700 per second on a single post. A single relational row under contention serializes on its row lock and typically sustains only hundreds to low thousands of updates per second, so updating posts.like_count directly cannot keep up.
- Source of truth:
reactions(post_id, user_id, type, updated_at)with primary key (post_id, user_id). An upsert is naturally idempotent: pressing like twice leaves one row. - Change events: the upsert emits a delta only when the state actually changes: none to like gives like +1; like to love gives like -1 and love +1; love to none gives love -1. Emit via an outbox table or change data capture so the event is not lost if the process crashes after commit.
- Aggregation: a stream job sums deltas per post over 1 second windows and applies one write per post per second, turning 16,700 writes per second into 1.
- Alternative: sharded counters, for example 64 sub-counter rows per hot post (about 260 writes per second each), summed on read or cached.
Design options
| Approach | Write throughput | Count freshness | Correctness risk |
|---|---|---|---|
| Update counter column directly | Low on hot rows | Immediate | Lock contention, timeouts |
| Redis INCR, periodic flush | Very high | Seconds | Lost increments on failover unless reconciled |
| Sharded counters | High, scales with shard count | Immediate but read sums N rows | More complex reads |
| Stream aggregation of deltas | Very high | Window length (1 to 10 s) | Needs dedup and idempotent apply |
Reconciliation and real systems
- Run a periodic job that recounts reactions rows for recently active posts and corrects counter drift.
- YouTube famously froze public view counts near 300 while it validated views (until it retired the "301+" freeze in 2015), a real example of choosing correctness over live freshness.
- LinkedIn's live video reactions travel through an in-memory publish/subscribe layer to viewers, while the durable counts are updated separately.