Scale Interview
System DesignMedium

Design a Likes System

Like / Unlike Counter System Design

Overview

A likes system lets a user tap a heart on an item (e.g., post, photo, or comment). The like count then goes up, and the user sees the new total. It sounds trivial, just "add one to a number". But the whole design is really built around two things:

  1. Keeping a like idempotent. If a user taps twice, or the request retries, it still counts as one like from that user.
  2. Keeping the count correct even when a post goes viral and gets thousands of likes per second.

The most obvious way to build it breaks both of these goals:

  1. Just increment a counter. A single counter records the total like of an item. So if the same user taps twice, or a request retries, one person's like is counted more than once. You also can't answer the question "has this user already liked it?", because no per-user record exists.
  2. One counter row. With the count in a single row, every like has to update that same row. On a viral item it becomes a hot key.

The fix is in the data model, which has two parts:

  1. Per-user like. Store each like as per-user with a uniqueness guarantee, so it stays idempotent.
  2. Sharded counters. Split the total into sharded counters, so there is no single hot row for a viral item.

You'll start by scoping the system, sizing it, choosing a data model, designing the API, and drawing the write and read paths. After that, you'll do three deep dives:

  1. Keep like/unlike idempotent under concurrency.
  2. Serve the count at scale.
  3. Answer the query paths ("has user X liked item Y" and "list my likes").

Functional requirements

At a high level, the system needs to support the full lifecycle of a like: users can like and unlike content, the UI can check whether a user has already liked an item, users can view their own liked items, and the system can show an accurate like count for each item.

The key functional requirements are:

  1. Like. A user likes an item (post, photo, comment). Liking is idempotent. It means that tapping twice or a retried request still counts as exactly one like from that user.
  2. Unlike. A user removes their like. After unliking, that user's like no longer counts. They can like again later.
  3. Has-liked. Answer the question "has user X liked item Y?". The result is used to render the heart filled or empty.
  4. List a user's likes. Retrieve a paginated list of items liked by a specific user, such as “posts you liked.”
  5. Count. Show the total number of distinct users who like an item. The like count should remain accurate even for viral items with very high traffic.

Here are some out-of-scope requirements that can be discussed only if time allows:

  1. Authentication and authorization.
  2. Notifications when someone likes a post.
  3. ML ranking of what to display.

Non-functional requirements

The key non-functional requirements are:

  1. Idempotent and accurate. Each user should contribute at most one like, even if they retry the request or tap multiple times. The displayed count should match the true number of unique users who liked the item.
  2. Read-heavy. Like counts are fetched every time an item is displayed. So read QPS is way higher than the write QPS, and should be fast.
  3. Handles write spikes. A viral item may receive a large burst of likes at the same time, and the system should continue working without outages or major slowdowns.
  4. Eventual consistency is acceptable for the count. The like count can be a few seconds behind. For example, it is okay if a viral post briefly shows 1,200,300 likes instead of 1,200,317.
  5. Availability over strong consistency. If the system cannot fetch the latest count, it should show a slightly stale value instead of failing the request.

Estimations

You'll do a quick back-of-the-envelope estimation here to understand the scale of the system. The exact numbers are not important. The goal is to use rough estimates to justify the design decisions.

Rough assumptions

  1. Daily active users (DAU): assume around 500M.
  2. Likes QPS. assume each active user likes around 20 items per day. With 500M DAU, that gives us about 10B likes per day. Dividing by 86,400 seconds/day, the average rate is roughly 100K likes/sec. At peak, it can be 2–3x higher.
  3. Like Count + has-liked read QPS. assume each active user opens the app around 10 times per day, and each open renders about 20 items. With 500M DAU, that is: 500M users × 10 opens/day × 20 items/open = 100B item renders/day. Each item render reads the like count, and for logged-in users may also read the has-liked flag. So this becomes roughly: 100B reads/day ÷ 86,400 seconds/day ≈ 1M+ reads/sec on average. Peak traffic can be several times higher.
  4. Viral spike. a popular celebrity post may receive millions of likes within a few minutes, which can create tens of thousands of writes per second for a single item.
  5. Like edge storage. as the system receives around 10B likes per day, it will accumulate about 3.65T like records in one year. Each like record stores fields such as user_id, item_id, and timestamp. If you estimate 50–60 bytes per row, then the system will have 180–220 TB data before replication (3.65T edges × 50–60 bytes). After adding replication, indexes, and metadata, the real storage can be much larger. This is far beyond what one machine can handle.

How the estimates affect the design

What the estimates tell us Design decision
Reads are much higher than writes. Like counts and has-liked checks may reach 1M+ reads/sec. Serve counts and has-liked results from cache when possible. Do not compute counts by scanning like records on every request.
A viral item can become a hot key. One item may receive tens of thousands of likes per second. Avoid updating a single counter row for that item. Use sharded counters to spread the write load.
Write QPS can be very high. The system may receive around 100K likes/sec on average, with higher peak traffic. Make like writes idempotent and use storage that supports high-throughput atomic writes. Avoid a design where all writes are serialized behind one database lock.
Like edge storage is very large. One year of likes can produce multiple trillions of records and hundreds of TBs of data. Store like edges in a sharded data store. Choose partition keys based on query patterns, such as checking whether a user liked an item or listing a user's liked items.
Counts do not need to be perfectly real-time. A count that is a few seconds stale is acceptable. Update displayed counts asynchronously and allow eventual consistency. The acting user should still see their own like or unlike immediately.

Warning

Avoid computing the like count by running SELECT COUNT(*) over the like edges on every read. With 1M+ count reads/sec and viral items potentially having tens of millions of edges, counting rows per request would be far too expensive. The count should be precomputed, maintained, and cached.

Core data model

To support the requirements we discussed, you would model two core entities:

  1. Like, which records who liked what.
  2. LikeCount, which stores the total like count for each item.
Entity Key fields
Like (user_id, item_id, created_at)
LikeCount (item_id, total)

The Like is the single source of truth. Each row represents one user liking one item. It is keyed by the pair (user_id, item_id) with a unique constraint on that pair, so the same user can only have one like record for the same item. That uniqueness is what makes like idempotent.

The LikeCount is derived from the Like. It is a maintained running total. If the count is lost or becomes incorrect, we can rebuild it from the Like edges.

Caution

Do not treat the LiveCount as the source of truth.

Where the data lives.

  1. The Like should live in a durable, sharded data store, such as DynamoDB or a sharded SQL/NoSQL table. The partition key should be chosen based on the query pattern.
  2. The LikeCount lives in an in-memory store (Redis or similar) that supports atomic increment/decrement, backed by the durable Like store.

API design

The system needs five main APIs:

  1. Like an item.
  2. Unlike an item.
  3. Check whether the user has liked an item.
  4. List items liked by a user.
  5. Get the like count for an item.

Like an item

This API records that the current user likes the item.

PUT /api/items/{item_id}/like

Body: none
Response:
{
    "liked": true,
    "count": 1200317
}

Several callouts:

  1. The client does not need to send user_id in the request body as the server can identify the user from the auth token.
  2. This API uses PUT instead of POST because the operation is idempotent. If the same user sends the request multiple times, the result is still one like, not multiple likes.

Here are major steps in the server side:

  1. Insert the (user_id, item_id, current_timestamp) into Like table if there is no row with same (user_id, item_id) key.
  2. If the insert succeeds, increment the item's like count.
  3. Return the latest liked state and the total count of the item so the client can immediately render the filled heart.

Unlike an item

This API removes the current user's like from the item.

DELETE /api/items/{item_id}/like

Body: none
Response:
{
    "liked": false,
    "count": 1200316
}

This operation is also idempotent. If the user has not liked the item, the request is a no-op and still returns like being false.

Here are major steps in the server side:

  1. Delete a row with key being (user_id, item_id) if it exists.
  2. If a like was actually removed, decrement the item's like count. This prevents retried DELETE requests from incorrectly decreasing the count multiple times.
  3. Return the latest liked state and count.

Check whether the user liked an item

This API checks whether the current user has liked the item.

GET /api/items/{item_id}/like

Response:
{
    "liked": true
}

The server does a point lookup for the key (user_id, item_id). The client uses the response to render the heart as filled or empty.

For a feed with multiple items, the client should not call this API once per item. Instead, use a batch API which can return the liked status for all items on the page:

GET /api/items/status?item_ids=a,b,c

Response:
{
    "items": [
        {
            "item_id": "a",
            "liked": true
        }
    ]
}

List a user's likes

This API returns the items a user has liked, usually newest first.

GET /api/users/{user_id}/likes?cursor=<opaque>&limit=20

Response:
{
    "items": [
        {
            "item_id": "...",
            "created_at": 1783142295
        }
    ],
    "next_cursor": ".."
}

To enable fast query, you create an index with paritition key being user_id and sort key being created_at. This way, listing a user’s likes becomes a simple ordered scan.

In addition, we use cursor-based pagination instead of offset-based. Cursor pagination works better at large scale because the database can continue scanning from the last item instead of skipping many rows.

Get an item's like count

This API returns the total number of distinct users who liked the item.

GET /api/items/{item_id}/count

Response: 
{
    "count": 1200317
}

The server serves the count from LikeCount table or a cache.

For feed pages, this should also support batching which allows one request to return counts for all items on the page:

GET /api/likes/counts?item_ids=a,b,c

Response:
{
    "items": [
        {
            "item_id": "a",
            "count": 1200317
        }
    ]
}

High-level architecture

There are two important paths in this system:

  1. Write path in the like and unlike APIs. It updates the Like table and updates the count only when the like actually changes.
  2. Read path in the Get APIs (e.g., count, has-liked). These are read-heavy.

You will go through the write path first, then the read path, and finally show how they fit together.

1. Write path: like / unlike

A like request should create the Like edge only once. The count should increase only when a new like edge is actually created.

Below flow shows the major steps in the write path:

  1. The client sends a PUT /like request to like an item, or DELETE /like request to unlike it.
  2. The Like Service writes to the DB.
    • For like, insert the (user_id, item_id) edge only if it doesn't already exist.
    • For unlike, delete the edge only if it exists.
  3. The store returns whether the edge actually changed.
    • If the like edge was newly inserted, this is a real like.
    • If the edge already existed, this is a duplicate request and should be ignored.
  4. The Like Service updates the counter only when the edge changed.
    • For a new like, increment the count.
    • For a real unlike, decrement the count.
    • For duplicate likes, do not change the count.
  5. Optionally, the service emits a like/unlike event to an event stream for analytics or downstream systems. This should not block the main request.
flowchart LR
    Client[Client] -->|"PUT like / DELETE unlike"| LikeSvc[Like Service]
    LikeSvc -->|"insert-if-absent (user_id, item_id)"| EdgeDB[(Edge Store - sharded, unique key)]
    EdgeDB -->|"was it a new edge?"| LikeSvc
    LikeSvc -->|"if new: increment a random shard"| Counter[(Sharded Counter - Redis)]
    LikeSvc -.->|"emit like/unlike event"| Stream[[Event Stream]]

The key rule is: the count changes only when the edge set actually changes. This is what keeps the count correct. A retry or double-tap does not create a new edge, so it should not increment the count again.

2. Read path: count + has-liked

Based on the estimate, the system may serve 1M+ read requests per second for counts and has-liked checks. Because of that, the read latency should be low (like < 200MS P90).

Below flow shows the major steps in the read path:

  1. The client asks for the like count and the has-liked state.
  2. The Read service gets the count from the counter store or count cache.
  3. For has-liked, the Read service does a point lookup of the (user_id, item_id) edge
    • Ideally, this is served from a cache
    • On cache miss, it falls back to the datastore (cache-aside).
  4. The Read Service returns both.
    • The current like count.
    • Whether the current user liked the item.
flowchart LR
    Client[Client] -->|"GET count / has-liked"| ReadSvc[Read Service]
    ReadSvc -->|"sum N shards (cached total)"| Counter[(Sharded Counter - Redis)]
    ReadSvc -->|"point lookup (user_id, item_id)"| EdgeCache[(Edge / has-liked cache)]
    EdgeCache -.->|"on miss"| EdgeDB[(Edge Store)]
    ReadSvc -->|"count + liked flag"| Client

For feed pages, these reads should be batched. Instead of making one request per item, the client should send one request for all visible item IDs and get back all counts and liked flags together.

Tip

Count reads and has-liked reads should be O(1) lookups, usually from cache. Never answer them by scanning all like edges at read time.

3. Combined architecture

Putting them together:

  1. The Like is the source of truth. It stores the real (user_id, item_id) like edges.
  2. The Counter Store stores a derived like count for each item.
  3. The has-liked cache speeds up point lookups for whether a user liked an item.
  4. The List Service reads the user's like edges directly when showing "items you liked".
flowchart LR
    Client[Client] --> GW[API Gateway]
    GW -->|"PUT like / DELETE unlike"| LikeSvc[Like Service]
    LikeSvc -->|"insert/delete-if-present (user_id, item_id)"| EdgeDB[(Edge Store - sharded, unique key)]
    LikeSvc -->|"on real change: incr/decr random shard"| Counter[(Sharded Counter - Redis)]
    LikeSvc -.->|"like/unlike event"| Stream[[Event Stream]]
    GW -->|"GET count"| ReadSvc[Read Service]
    GW -->|"GET has-liked (batched)"| ReadSvc
    ReadSvc -->|"sum N shards"| Counter
    ReadSvc -->|"point lookup"| EdgeCache[(has-liked cache)]
    EdgeCache -.->|"on miss"| EdgeDB
    GW -->|"GET list-likes"| ListSvc[List Service]
    ListSvc -->|"read user's edges (partitioned by user_id)"| EdgeDB

Writes and reads are separated:

  1. Write requests update the edge first, then update the counter only if the edge changed.
  2. Read requests serve counts and liked flags from fast stores or caches.

This separation is important for viral items. A viral post may receive many likes at the same time, but count reads should not be blocked by the write storm. Users should still be able to read the count quickly, even if the displayed count is slightly stale.

Deep dives

1. Idempotent like/unlike: correctness under concurrency

One user should count as one like for one item. It should not matter if the user taps multiple times, the request is retried, or the network sends a duplicate request. The like count should still increase at most once for that user. If this is wrong, the count can drift and users will no longer trust it.

Why "read count, add one" is the wrong model.

A tempting design is:

  1. Read the current count.
  2. Add one.
  3. Write the new count back.

This is a read-modify-write pattern, and it breaks for two reasons:

  1. It doesn't know whether the user already liked the item. If the user double-taps, the system may add one like twice.
  2. It has race conditions. Two requests may read the same count at the same time. For example, both read 100, both write back 101, and one update. Or even worse, dupliate requests may increase the count more than once for the same user.
read-modify-write, one user double-taps:

  tap A: read count=100 ─┐
  tap B: read count=100 ─┤ both read the same value
  tap A: write 101       │
  tap B: write 101       ┘ → count is 101, but a second tap could have written 102.

The fix: model each like as an unique edge. The correct approach is to store each like as a row keyed by the pair (user_id, item_id). In addition, this key should have a unique constraint. That means one user can only have one like edge for one item. If the same user sends the like request again, the database doesn't create a second row.

The like operation becomes insert-if-absent:

  1. if the like edge doesn't exist, create it.
  2. if the edge already exists, do nothing.

The count should only change when the edge changes.

insert-if-absent, one user double-taps:

tap A: INSERT (user_id, item_id) → created new edge  → count += 1
tap B: INSERT (user_id, item_id) → already exists    → count unchanged

Result:
exactly one edge is created, and the count increase exactly once

For a like:

  1. If a new edge is inserted, increment the count.
  2. If the edge already existis, do not change the count.

For an unlike:

  1. If a new edge is removed, decrement the count.
  2. If the edge has been removed, do not change the count.

This makes the count track the number of unique like edges, not the number of taps or requests.

Handling rapid like / unlike / like

A user may quickly tap

like -> unlike -> like

These requests may arrive out of order or be processed concurrently. In this case, each operation should be treated as a state change, not just a blind counter update.

  1. like means "ensure the edge exists".
  2. unlike means "make sure the edge doesn't exist".

However, idempotency alone is not enough here. A stale unlike request could arrive after a newer like request and incorrectly remove the edge. To handle this, attach a sequence number to each like/unlike operation.

The Like stores the latest sequence number it has seen. A write is applied only if its sequence number is newer than the sequence number currently stored (compare-and-set). For example:

  1. A newer like with sequence number 3 wins over an older unlike with sequence number 2.
  2. A stale unlike with sequence number 5 is rejected if the edge has already seen a newer sequence number 6.
  3. The final edge state matches the user’s latest action.

Another option is to serialize all writes for the same (user_id, item_id) edge on the same partition, so they are applied in a defined order.

The key idea is: Idempotency prevents duplicate likes. A sequence number, or per-like edge serialization, handles rapid like/unlike race conditions.

Keeping the count correct

Every count update should be based on a real edge change:

  1. Increment only when a like edge is newly created.
  2. Decrement only when a like edge is actually removed.

The Like table remains the sourth of truth. The count is a maintained cache of:

COUNT(distinct like rows) for an item

If the service crashes after writing the edge but before updating the counter, the count may temporarily drift. For example, the edge exists, but the counter was not incremented. That is why the system needs background reconciliation:

  1. Periodically recompute counts from the edge store.
  2. Fix counters that are missing increments or decrements.
  3. Run this offline or asynchronously, not on the read path.

For very large or viral items, reconciliation may be expensive because the item's edges may be spread across many shards. That is acceptable because reconciliation runs rarely and outside the hot path.

Warning

Handle the crash between "insert edge" and "increment count". If the process dies after creating the edge but before incrementing the counter, the count will be temporarily wrong. Since the edge store is the source of truth, a background reconciliation job can rebuild or repair the count from the edges.

2. Count at scale: handling viral items

A viral item can receive many likes at the same time. For example, one popular post may get tens of thousands of likes per second. If every like updates the same counter row for that item_id, that row becomes a hot key. All write traffic goes to one place, which can slow down the system or cause failures.

The goal is to avoid putting all writes for a viral item on one key.

One shared counter row (avoid)

The simplest design is to keep one counter row per item: count(item_id)

Every time someone likes the item, the system increments that same counter. For example:

UPDATE item_counts
SET count = count + 1
WHERE item_id = ?

This is correct from a data correctness point of view because the increment is atomic. It will not lose updates like a read-modify-write approach. But it has a scalability problem: every like for the same viral item writes to the same key. For example, if one post gets 40,000 likes/sec, all 40,000 writes go to one counter row. That single key becomes the bottleneck.

Worked example

a post takes 40,000 likes/sec:

flowchart LR
    W["40,000 likes/sec<br/>all writers"] -->|"every write"| C["count(item_42) = 1,200,317<br/><b>ONE</b> key / row / shard"]
    C --> B["serializes on one key<br/>SQL: 40K writes queue on one row lock<br/>Redis: one key pins one shard's CPU → bottleneck"]
    classDef hot fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
    class C hot

Atomic increment solves correctness, but it does not solve write contention.

  • Pro: simple design, easy to read the count, and atomic increment keeps the count correct.
  • Con: a viral item can overload one row or one key. The throughput for that item is limited by that single hot key.
  • Verdict: avoid this for hot items. It works only when no single item receives too many likes. Once an item goes viral, the counter becomes a bottleneck.
Batched counting (works)

We still use one counter row per item. Instead of writing every single like to the durable store immediately, the system buffers the changes for a short time and flushes them in batches. For example, if an item receives 312 likes in two seconds, the system does not need to write 312 separate updates to the durable store. It can write one update:

count += 32

This reduces write pressure on the durable store.

Worked example:

flowchart LR
    L["likes stream in"] --> BUF["in-memory delta buffer<br/>item_42: +312 this second"]
    BUF -->|"flush every ~2s"| D["durable count += 312<br/>one write, not 312"]
    classDef buf fill:#fef9c3,stroke:#ca8a04,color:#713f12;
    class BUF buf

Why this helps

This design decouples the raw like traffic from the durable database. The durable store sees fewer writes, and the count becomes naturally eventually consistent, which is acceptable for like counts. The count may be a few seconds behind, but that is usually fine for a social product.

  1. Pro:
    • Reduces write load on the durable count store.
    • Turns many small writes into fewer batched writes.
    • Works well with eventual consistency.
  2. Con:
    • The count can be temporarily stale.
    • A crash can lose unflushed updates unless reconciliation exists.
    • A single buffer key can still become hot for viral items.
    • Approximate counting needs product approval because users may expect like counts to be mostly accurate.
  3. Verdict:
    • This is a good batching layer, but it should not be the only solution for viral items.
    • It works best when combined with sharded counters, so writes are both batched and spread across multiple keys.
Sharded counters (recommended)

The recommended approach is to split one item’s counter into multiple smaller counters, called shards. Instead of having one counter for an item item_42_count, we create multiple counters:

item_42_count_shard_0
item_42_count_shard_1
...
item_42_count_shard_19

When a user likes the item, the service increments one randomly chosen shard. This spreads the write load across many keys instead of sending every write to one key. To read the total count, the system sums all shards.

Worked example — N = 20 shards for the viral item:

flowchart LR
    W["40,000 likes/sec<br/>each picks a random shard 0..19"] --> S0["shard 0<br/>~2,000/s"]
    W --> S1["shard 1<br/>~2,000/s"]
    W --> Sd["… shards 2–18<br/>~2,000/s each"]
    W --> S19["shard 19<br/>~2,000/s"]
    S0 --> R["READ = sum of 20 shards = 1,200,317<br/>(refreshed into a cached total)"]
    S1 --> R
    Sd --> R
    S19 --> R
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class S0,S1,Sd,S19 good

Because reads are very frequent, we usually do not sum the shards on every request. Instead, the system periodically sums the shards and stores the result in a cached total. Most count reads then become a single cache lookup.

Handling unlike

For unlike, the service decrements one shard. It does not have to be the same shard that was incremented before. Since users only read the total sum across all shards, it is okay if one shard temporarily becomes lower than expected, as long as the total count is correct.

Consistency

The total count may be slightly stale because:

  1. shard updates happen continuously.
  2. summing multiple shards is not a single atomic read.
  3. the cached total may refresh every second or two.

This is acceptable for like counts. A few seconds of drift is fine, as long as the user's own like state updates immediately.

Product design

In practice, we can combine sharded counters with the batching layer to achieve both high write throughput and fast reads:

  1. New likes are written to random counter shards.
  2. Shard totals are periodically summed.
  3. The summed total is stored in cache.
  4. Count reads are served from the cached total.

Here is the trade-off analysis of this approach:

  1. Pro:
    • Removes the single hot key problem.
    • Spreads writes across multiple shards.
    • Each shard can still use atomic increment/decrement.
    • Scales better by increasing the number of shards for hot items.
  2. Con:
    • Reads need to sum multiple shards, unless the total is cached.
    • The number of shards needs tuning.
    • The design has more moving parts than one counter row.
  3. Verdict:
    • Sharded counters are the standard solution for hot-key counters at scale.

Why a slightly stale count is okay

The like count does not need to be perfectly real-time. It is usually fine if a viral post briefly shows:

1,200,300 likes

and a few seconds later updates to:

1,200,317 likes

Users care more that their own action feels immediate. After they tap like, the UI should show that their like was registered. So the important rule is: The user's own like state should update immediately, but the global count can be slightly stale.

Warning

Do not put all likes for a viral item into one counter row. Use sharded counters so the write load is spread across multiple keys.

3. Queries at scale: has-liked and list user likes

There are two important read queries based on the Like edge:

  1. Has user X liked item Y?
  2. List all items liked by user X?

How we partition the Like edge store decides which queries are cheap and which queries are expensive. The key tradeoff is: With one copy of the data, we usually cannot make every query cheap. We need to choose the partition key based on the most important query.

Query 1: Has user liked this item?

This query checks whether a specific edge exists using (user_id, item_id). It is a point lookup. As we already have the index on this key, this query should be fast. It should not scan all likes. It should check one key, ideally from cache, and fall back to the edge store on a cache miss. This query is used every time we render the heart state, so it needs to be an O(1) lookup.

Query 2: Has user liked this item?

This query powers pages like: "Posts you liked". It needs to return all items liked by one user, usually newest first and paginated. To make the query fast, we are going to build additional index (like Global Secondary Index in DynamoDB) with the paritition key being user_id and the sort key being created_at.

Caution

Do not serve "list my likes" by scanning every shard. Choose the partition key for the query you must make cheap, and duplicate the edge index only if both directions must be fast.

Tradeoffs and bottlenecks

  1. Like edge as source of truth and count as cache. The Like edge is the authoritative record of who liked what. The count is only a derived value maintained for fast reads. This means we need a reconciliation job to rebuild or fix counts from edges, but it gives us correctness, idempotency, and recovery.
  2. Hot-key writes vs simple reads. A single counter row is easy to read because it is just one lookup. However, it becomes a write bottleneck for viral items. Sharded counters spread the writes across multiple keys, but reads become slightly more complex because we may need to sum multiple shards. We reduce that read cost by caching the final total.
  3. Exact count vs slightly stale count. Like counts do not usually need to be perfectly real-time. A count that is a few seconds stale is acceptable. This allows us to update counts asynchronously, use caches, and shard counters. If we required a strongly consistent count on every read, the design would be much harder and less scalable.
  4. Large edge storage. The system may store hundreds of billions or even trillions of like edges. Each edge is small, but the total volume is huge, so the edge store must be sharded. The main challenge is not the size of one row, but the total storage, write throughput, and number of shards needed.

Extensions if asked

If the interviewer wants to go beyond the core like system, we would only add the extension that changes the design meaningfully.

  1. Who liked this item. Return a paginated list of users who liked an item. We can add a second index optimized for item-side queries.
  2. Reaction types. Instead of only supporting likes, support reactions such as love, laugh, or angry. The Like edge would include a reaction_type field, and the system would maintain a separate count per reaction type.
  3. Like notifications. Notify the content author when someone likes their post. This should be handled by a separate notification system that consumes like/unlike events from the event stream.
  4. Anti-abuse and fake likes. Add rate limits, detect bot-driven like spikes, and correct inflated counts. This is a separate abuse-detection system built on top of the core like events.

What interviewers look for & common mistakes

Interviewers usually care about a few key ideas:

  1. Make likes idempotent. Store each like as a unique (user_id, item_id) edge. Increment the count only when a new edge is actually created and do not use a simple "read count, add one, write back" design.
  2. Call out the hot-key problem. A viral item can receive many likes at the same time. If all likes update one counter key, that key becomes a bottleneck. We can fix this with sharded counters and cache the final total.
  3. Serve reads from cache. Like counts and has-liked checks are read very often. They should come from maintained counters or caches, not by scanning or counting like edges on every request.
  4. Explain the partitioning tradeoff. We choose the partition key based on the most important query, and duplicate the index only if needed.
  5. Accept eventual consistency for counts. The global count can be a few seconds stale. From the user's product experience perspective, the system should make user's own like state correct.

Quick mistake checklist

Before finishing, check these points:

  1. Did I make the like operation idempotent using a unique (user_id, item_id)?
  2. Did I treat the like edge as the source of truth and the count as a derived value that can be rebuilt?
  3. Did I update the count only when a real like edge was created or deleted?
  4. Did I identify the viral-item hot-key problem?
  5. Did I use sharded counters instead of one counter row?
  6. Did I serve the count from a maintained counter or cache instead of counting edges on every read?
  7. Did I choose a partition key intentionally and explain the user_id vs item_id tradeoff?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →