Likes Counter
Design a scalable likes counter for celebrity posts receiving millions of writes per second: handling hot-key write amplification, aggregating counts with acceptable staleness, and preventing double-likes.
What is a likes counter for high-profile posts?
A likes counter tracks how many users have liked a post and enforces that no single user can like the same post twice. The deceptively simple task of incrementing a number becomes a distributed-systems problem when a popular post receives a burst of concurrent likes.
The design has two separate correctness concerns: spread the counter writes so one logical post does not become a hot key, and keep an exact per-user/post membership record so retries cannot create duplicate likes. The live count can be an eventually consistent materialization; the uniqueness decision cannot be probabilistic.
TL;DR
Keep an exact, durable like record keyed by (post_id, user_id) as the correctness authority. Use a Bloom filter only as a memory-efficient hint for likely duplicates, never as the final acceptance decision. On an exact create-if-absent success, increment one of K counter shards and publish an idempotent like event; reads MGET the shards and sum them.
Persist accepted like records and counter updates through a durable, partitioned event or storage path. A relational table with a unique constraint is a useful safety net, but a single primary cannot absorb the illustrative celebrity burst. Redis is the low-latency materialization, not the only durable copy. Unlikes require an exact state transition and a decrement event; a Bloom filter cannot support removals.
Scope and assumptions
The following is an illustrative single-region interview baseline. The figures describe the exercise rather than a product guarantee:
- A celebrity post may receive up to 100,000 like attempts/second for a short 2β5 minute burst; ordinary posts are much quieter.
- Count reads are read-heavy in steady state, while the burst can temporarily approach a 1:1 read/write mix. A count may be 1β5 seconds stale.
- The API supports binary like/unlike state and a count. Notifications, named reactions, a paginated list of likers, and global multi-region write consistency are out of scope.
- The counter is sharded across
Kindependent keys, withK=10as an illustrative starting point. The exact value follows measured per-key capacity and read amplification. - The exact membership gate is partitioned by a key that spreads users, and the canonical record store is replayable or rebuildable. Redis loss must not silently change the uniqueness contract.
Functional Requirements
Core Requirements
- Users can like or unlike a post.
- Any user viewing a post can see the current like count.
- A user can only like a post once (no double-likes).
Below the Line (out of scope)
- Like notifications delivered to the post author
- Displaying who liked a post (partial like list for display)
- Reactions beyond a binary like (emoji reactions)
- Real-time push of count updates to all active viewers
The hardest part in scope: Writing 100,000 likes per second to the same logical counter for a celebrity post without saturating a single database row or cache key. The uniqueness constraint adds a second layer of difficulty: we must prevent double-likes at a fraction of the per-like write cost.
Like notifications are below the line because they introduce a fan-out problem that is orthogonal to the counter design. An extension would publish a like_event to Kafka and have a notification service consume it asynchronously, separate from the write path.
Displaying who liked a post is below the line because it requires paginated queries across potentially millions of records. An extension would store (post_id, user_id, liked_at) in a dedicated table with an index on (post_id, liked_at DESC) for cursor-based pagination.
Real-time push of count updates is below the line because it adds WebSocket fan-out complexity without changing the counter storage design. An extension could use Server-Sent Events, publishing count deltas from the read service on a configurable threshold (for example, every 1,000 new likes).
Non-Functional Requirements
Core Requirements
- Write throughput: Celebrity posts receive up to 100,000 like writes per second for the first 2-5 minutes after posting. Average posts receive far less (roughly 1 like per second at peak).
- Read throughput: With 500M DAU and an average of 20 post views per day, the system processes approximately 115,000 count reads per second.
- Write latency: A like write acknowledges in under 200ms p99.
- Read latency: A like count read returns in under 50ms p99.
- Availability: 99.99% uptime. Availability over consistency: a stale count that is 1-5 seconds behind is fully acceptable.
- Uniqueness: Each user can like a post at most once. Duplicate writes must be rejected.
Below the Line
- Sub-5ms read latency via CDN edge caching (requires additional infrastructure)
- Global multi-region write consistency (out of scope for this design)
Read/write ratio: During the peak of a celebrity post, the ratio briefly inverts to roughly 1:1 (equal writes and reads). For average posts and steady-state traffic, reads outpace writes by roughly 10:1. Both extremes need addressing: the peak write case determines the counter design, and the read-heavy steady state determines the caching strategy.
The 100,000 writes-per-second target rules out any design that routes all writes to a single storage node. A typical Redis node handles 100,000-200,000 ops/second total.
With no write spreading, a single hot key can saturate the CPU and network budget of one Redis primary. The 1β5 second stale-count tolerance is the most consequential NFR: it permits batching, in-memory aggregation, and asynchronous persistence, but it does not relax the exact like/unlike state transition.
30-second answer / outline
- Authenticate the caller and derive
user_idfrom the token; do not accept it from the request body. - Check a Bloom filter as a fast hint, then perform an exact atomic create-if-absent on
(post_id, user_id). Only an exact success is a new like. - Spread accepted count increments across
KRedis shard keys and publish an idempotent event for durable persistence and replay. - Read counts with one pipelined
MGETacross the shards. Return an explicit freshness/approximation signal. - Use a partitioned durable store with
UNIQUE(post_id, user_id)or equivalent conditional writes; rebuild Redis from accepted records/events after cache loss.
5-minute explanation
The direct-to-database design is correct but makes the database process every attempt and every count query. A single Redis counter improves latency but leaves all traffic on one hot key. Sharded counters solve the write concentration: a new accepted like chooses a shard, and a read sums the small set of shards.
The important caveat is deduplication. A Bloom filter has false positives and can be stale after a restart; two concurrent requests can also observe a negative before either filter update is visible. Treat it as a fast rejection hint only. An exact, partitioned membership store or atomic database conditional write decides whether the like exists. The unique constraint remains the final invariant, and the count materialization is derived only from accepted transitions.
The live count and canonical records have different latency goals. A write can acknowledge after the exact gate and live counter/event path succeeds, while a durable consumer batches accepted records. If a counter increment or event publish fails after the exact claim, retry it idempotently or mark the claim for reconciliation; do not assume two independent writes are atomic. On Redis loss, replay the durable event log or recalculate the shard totals.
45-minute interview approach
This is a time-boxed interview plan, not a promise that the article can or should be read in 45 minutes.
- 0β5 minutes β Clarify the contract: Ask whether the count must be exact, whether like/unlike is required, how duplicates are defined, the burst duration, freshness tolerance, and regional scope.
- 5β10 minutes β Establish scale: Use the illustrative 100,000 attempts/second burst, count-read mix,
Kshard assumption, and expected record size. Separate attempts, accepted likes, and count reads. - 10β15 minutes β Define APIs and invariants: Show authenticated like/unlike endpoints, count reads, an idempotency key if clients retry, and the invariant that one
(post_id, user_id)has at most one active like. - 15β22 minutes β Draw the hot path: Show the API, exact membership gate, Bloom hint, sharded counter, and durable event log. Explain which response is approximate and which decision is exact.
- 22β30 minutes β Deep dive on persistence: Compare synchronous database writes, timer flushes, and write-behind events. Cover event IDs, conditional inserts, consumer retries, reconciliation, and Redis rebuilds.
- 30β36 minutes β Deep dive on scaling: Discuss shard count, random versus deterministic shard selection, count-read fan-in, CDN caching, and per-post hot-key detection.
- 36β42 minutes β Reliability, security, and operations: Cover cache failure, queue lag, duplicate events, deletion semantics, authorization, rate limits, abuse, and metrics for freshness and drift.
- 42β45 minutes β Trade-offs and close: Compare exact storage with probabilistic hints, Redis materialization with database aggregation, and synchronous durability with asynchronous latency. Recap the two invariants.
Core Entities
- Like: The canonical record that a specific user liked a specific post. Contains
post_id,user_id, andliked_at. Enforces uniqueness at the database layer via aUNIQUE(post_id, user_id)constraint. - Post: The content item being liked. Treated as an external entity. The counter design does not own the post record.
- LikeCount: The aggregated count for a post. In the naive design this is a derived value from
COUNT(*). In the evolved design it is a materialized value held in Redis shards and periodically flushed to the database.
Keep the entity list short in the first pass. The full schema, indexes, and flush semantics are deferred to the deep dives; these three entities are sufficient to drive the API and high-level design.
API Design
Two functional requirements drive the API shape.
FR 1 and FR 3 - Like or unlike a post:
POST /posts/{post_id}/likes
Authorization: Bearer <token>
Response: 200 OK | 409 Conflict (already liked)
DELETE /posts/{post_id}/likes
Authorization: Bearer <token>
Response: 200 OK | 404 Not Found (not liked)
POST creates a like record. DELETE removes it. Both derive user_id from the auth token rather than the request body, preventing any user from spoofing another user's like action.
The 409 on POST and 404 on DELETE are the signals the client uses to update button state without a separate round trip.
FR 2 - Read the current like count:
GET /posts/{post_id}/likes/count
Response: { "count": 14823901, "is_approximate": true }
The is_approximate field is the honest signal to clients that the count may be 1-5 seconds stale. Surfacing this explicitly prevents product teams from building features that depend on exact real-time counts, which this system deliberately does not guarantee at peak load.
High-Level Design
Critical flows
- New like: Authenticate, consult the probabilistic hint, claim
(post_id, user_id)in the exact gate, then increment one counter shard and publish an idempotent accepted-like event. - Count read: Read all
Kshards in one pipelined request, sum the values already applied, and expose the possible freshness window to the caller or cache. - Unlike and recovery: Remove or tombstone the exact membership record, publish a decrement transition, and rebuild the Redis materialization from accepted transitions after a cache or consumer failure.
1. Naive approach: direct database writes
The simplest design routes every like directly to a relational database. The database enforces uniqueness via a UNIQUE(post_id, user_id) constraint and computes the count via COUNT(*) on each read.
Components:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.
Related Articles
Walk through a complete Twitter design, from a bare-bones tweet service to a hybrid fan-out architecture serving home timelines to 200M DAU in under 300ms.
Design Instagram's photo upload, hybrid fan-out feed, and CDN delivery for 500M DAU, covering the media pipeline and petabyte-scale Cassandra storage.