SYSTEM_DESIGN
System Design: Like and Reaction System
System design of a scalable like and reaction system covering real-time counter updates, idempotent toggling, reaction aggregation, and handling viral content spikes for platforms with billions of daily interactions.
Requirements
Functional Requirements:
- Users can like or react (Love, Haha, Wow, Sad, Angry) to posts, comments, and messages
- Reactions are toggleable: tapping the same reaction removes it; tapping a different one switches
- Display total reaction count and breakdown by reaction type on each post
- Show a paginated list of users who reacted ("Liked by user1, user2, and 1,234 others")
- Users see their own reaction state reflected instantly (optimistic UI)
- Notify the post creator when their post receives reactions (batched)
Non-Functional Requirements:
- Handle 5 billion reaction events per day (58K writes/sec average, 300K/sec peak during viral events)
- Reaction count reads: 50 billion/day (580K reads/sec) — every post render fetches counts
- Toggle latency under 50ms (perceived); eventual consistency for counts is acceptable within 5 seconds
- 99.99% availability; reaction data must never be permanently lost
- Idempotent: duplicate events must not inflate counts
Scale Estimation
With 5 billion reaction events/day, each event containing user_id (8 bytes), target_id (8 bytes), reaction_type (1 byte), timestamp (8 bytes), and action (1 byte = add/remove), each event is approximately 26 bytes — 130GB raw event data/day. The reaction state table (which user reacted to which post with which reaction) grows by approximately 3 billion net new rows per day (accounting for toggles). At 26 bytes per row, that is 78GB/day, or 28TB/year. The counter table (reaction counts per post) has one row per post with active reactions — approximately 5 billion rows at 50 bytes each (post_id + 6 reaction type counts) = 250GB total, easily fitting in a Redis cluster.
High-Level Architecture
The reaction system has two paths: the write path for toggling reactions and the read path for fetching counts and reactor lists. The write path: when a user taps a reaction, the client sends a request to the Reaction API, which performs an idempotent upsert to the Reaction State Store (Cassandra, partitioned by target_id) and publishes an event to Kafka. A Counter Update Service consumes from Kafka and atomically updates the reaction counts in Redis using a Lua script that increments one reaction type and decrements another (for reaction switches) in a single atomic operation. A Notification Batcher consumes the same Kafka topic and aggregates reaction events per post into batched notifications ("Alice and 5 others liked your post") sent every 30 seconds.
The read path: when a post is rendered, the client fetches reaction counts from the Reaction Counter Cache (Redis) and the user's own reaction state from the Reaction State Cache (also Redis, with a 1-hour TTL). The counter cache holds pre-aggregated counts: reactions:{post_id} is a Redis hash with fields like, love, haha, wow, sad, angry, each containing the count. For the reactor list ("Liked by..."), the API queries Cassandra for the most recent N reactors with reaction_type filter.
Core Components
Reaction State Store
The reaction state is stored in Cassandra with the following schema: partition key = target_id (post or comment ID), clustering key = user_id. Columns: reaction_type (TINYINT), created_at (TIMESTAMP), updated_at (TIMESTAMP). This design allows efficient queries for both 'all reactions on a post' (partition scan) and 'did user X react to post Y' (single-row lookup). The toggle operation uses a Cassandra lightweight transaction (LWT) with IF EXISTS semantics to ensure idempotency — if the same reaction already exists, it is a no-op; if a different reaction exists, it is updated. LWT adds approximately 10ms latency but guarantees correctness under concurrent toggles.
Atomic Counter Service
Reaction counts are maintained in Redis using a Lua script for atomicity. The script for a reaction toggle: (1) read the current reaction for this user-post pair from a Redis hash user_reactions:{post_id}; (2) if the same reaction exists, decrement the count and remove the user entry; (3) if a different reaction exists, decrement the old type and increment the new type; (4) if no reaction exists, increment the new type. All operations execute atomically within the Lua script on a single Redis node (the node owning the post_id's hash slot). This eliminates race conditions without distributed locks. Counter values are persisted to Cassandra every 5 minutes by a flush job.
Notification Batching Service
Sending a push notification for every single like on a viral post would overwhelm both the notification system and the user's device. The Notification Batcher aggregates reaction events per target_id in a time window (30 seconds). It maintains a Redis hash notification_batch:{post_id}:{window} accumulating reactor user_ids and counts. At window close, it generates a single notification: "Alice, Bob, and 47 others reacted to your post" and sends it via the platform's Push Notification Service (Firebase Cloud Messaging / APNs). For posts receiving >100 reactions/minute, the batch window extends to 5 minutes to further reduce notification noise.
Database Design
Cassandra stores the source-of-truth reaction state. Table reactions: partition key target_id (BIGINT), clustering key user_id (BIGINT), columns reaction_type (TINYINT), created_at (TIMESTAMP). Table reaction_counts: partition key target_id (BIGINT), columns like_count, love_count, haha_count, wow_count, sad_count, angry_count (all COUNTER type). Cassandra's native counter columns handle concurrent increments without conflicts, but counters cannot be read-modify-written atomically with non-counter columns, hence the separate table. The reactions table is sharded across 200 Cassandra nodes with NetworkTopologyStrategy (RF=3) across 3 datacenters.
Redis serves as the primary read cache. reactions:{post_id} (hash with reaction type counts) has no TTL — it is updated on every write. user_reactions:{post_id} (hash mapping user_id to reaction_type) has a 1-hour TTL and is populated on cache miss from Cassandra. For posts with >100K reactions, the user_reactions hash is not cached (too large) — the user's own reaction state is stored separately in my_reaction:{user_id}:{post_id} with a 24-hour TTL. Total Redis memory for 5 billion post counters: approximately 250GB across a 50-node Redis Cluster.
API Design
POST /api/v1/reactions— Toggle a reaction; body contains target_id, target_type (post/comment), reaction_type; idempotent; returns the new reaction state and updated countsDELETE /api/v1/reactions?target_id={id}— Remove reaction from a target; returns updated countsGET /api/v1/reactions/counts?target_ids={id1,id2,...}— Batch fetch reaction counts for multiple posts (used in feed rendering); returns a map of target_id to reaction breakdownGET /api/v1/reactions/{target_id}/reactors?type={reaction_type}&cursor={user_id}&limit=20— Paginated list of users who reacted with a specific type
Scaling & Bottlenecks
The primary bottleneck is handling reaction spikes on viral posts. A single viral post can receive 100K reactions/sec — all hitting the same Cassandra partition and the same Redis hash slot. For Cassandra, the hot partition problem is mitigated by using a bucketed partition key: target_id:bucket where bucket = user_id % 10. This distributes writes across 10 partitions per post, with reads requiring a scatter-gather across all 10 buckets. For Redis, the hot key problem is addressed by replicating the counter to multiple Redis nodes using client-side sharding: the Reaction API writes to reactions:{post_id}:shard_{n} where n = random(0,4), and reads sum across all 5 shards. This distributes both read and write load._
The Counter Update Service (Kafka consumer) must maintain ordering per target_id to prevent count drift from out-of-order processing. Kafka's partition key is set to target_id, ensuring all events for a post land on the same partition and are processed in order by a single consumer. With 500 Kafka partitions, each consumer handles approximately 116 events/sec (average) — well within a single consumer's capacity. During viral events, consumer lag is monitored via Burrow, and additional consumer instances are spun up with Kafka's cooperative rebalancing to redistribute partitions.
Key Trade-offs
- Cassandra LWT for idempotency vs. application-level dedup: LWT guarantees correctness at the storage layer with ~10ms overhead; application-level dedup (checking Redis before writing) is faster but vulnerable to cache misses causing duplicate counts
- Separate counter table vs. computed counts: Maintaining pre-aggregated counters (updated on write) trades write amplification for O(1) read performance — with a 100:1 read/write ratio for counts, this is the correct optimization
- Redis Lua scripts vs. distributed locks: Lua scripts provide atomicity within a single Redis node without lock overhead; the constraint is that all data for the atomic operation must live on the same node (same hash slot)
- Bucketed partitions for hot posts vs. single partition: Bucketing distributes writes but complicates reads (scatter-gather across buckets) and the reactor list query (must merge-sort across buckets) — only applied for posts exceeding a hotspot threshold
GO DEEPER
Master this topic in our 12-week cohort
Our Advanced System Design cohort covers this and 11 other deep-dive topics with live sessions, assignments, and expert feedback.