System design interview

Design a view and like counter

Sharded counters, write batching, unique viewers and idempotent likes.

At a glance

Level
Foundation
Topics
Sharding, Caching
Main parts
Counter API · Batch buffer · Count cache · Likes DB · Unique sketch · View shards
What you'll learn
Practise this problem
Overview
ClientCounter APIBatch bufferCount cacheLikes DBUniquesketchView shardsClientCounter APIBatch bufferCount cacheLikes DBUnique sketchone per itemView shards

Press Play to follow a request through the design, or step through it.

  • Client and edge
  • Service
  • Data store
  • Cache and queue
  1. A viewer opens an item. The client sends a view event with the viewer id and the item id.
  2. The API tries to set a dedupe key for this viewer and item, with a 30-minute expiry. The set fails if the key exists.
  3. A new key means a new view. The API adds it to this server's in-memory batch, which is just a counter per item.
  4. Once a second, the buffer flushes. Each item's total is added to one randomly chosen shard row.
  5. It also sends a small sketch of the viewer ids, which the store merges into the item's unique-viewer estimate.
  • Client
  • Counter API
  • Batch buffer: sums, flush each 1 s
  • Count cache: dedupe keys, totals
  • Likes DB: rows and like shards
  • Unique sketch: one per item
  • View shards: 16 rows per hot item
On this page

In 2 minutes

Key ideas
  • The numbers. The total is modest. The danger is one hot counter.
  • Hot counters. A counter split over 16 rows.
  • Write path for views. Add up in memory and flush every second.
  • Unique viewers. A HyperLogLog sketch.
  • Like totals. A counter updated in the same transaction as the like row.
Read the 2-minute answer

I would treat views and likes as two different problems. Views can run a few seconds behind and be a little approximate, so I make them cheap. A view first checks a dedupe key for the viewer and item with a 30-minute expiry, so refreshes count once. A new view is added to an in-memory buffer on the API server, and a timer flushes the sums once a second.

With 20 servers, a viral item that gets 100,000 events a second causes only about 20 increments a second in the store. Those land on one of 16 shard rows, chosen at random, and a read adds the rows up and caches the sum for a couple of seconds. Unique viewers use a HyperLogLog sketch, 12 KB with about 0.8% error, which servers merge by taking the larger register values. Likes need to be exact.

I store a row per person and item with a unique key, and change a counter in the same transaction only when the row changes, so retries and double taps never double count. The counter is sharded by user id. The trade-off is that a crash can lose a second of views, in exchange for a write path that never overloads one counter.

Step 1 of 6: Clarify the scope

Questions to ask first

Ask these before you draw anything. Guess why each answer changes the design, then open it to check.

Does every play count as a view, or one per viewer in a time window?
Why it matters: Counting each viewer once needs dedupe state. Counting raw plays needs none, but it is easy to inflate.
Must the view total be exact, or may it run a few seconds behind and be approximate?
Why it matters: Allowing lag and small error lets us buffer writes and use sketches, which is far cheaper than exact counts.
Can a person unlike, and must the like total be exact?
Why it matters: Toggling means a like is state per person and item, not just a number to add to.
How skewed is traffic across items?
Why it matters: If a few items take most of the writes, the real problem is one hot key, not the total volume.
Do we need counts by day, country or device?
Why it matters: Each breakdown multiplies the number of counters, so it is a separate analytics system, not part of this one.

Requirements

Functional

  • Record a view of an item and show its view count.
  • Like and unlike an item, once per person, and show the like count and whether the current user liked it.
  • Show counts for a whole page of items in one call.
  • Count repeat views by the same viewer only once in a 30-minute window.

Non-functional

  • Absorb 100,000 view events a second on a single viral item without dropping any.
  • Count read: p99 under 20 ms.
  • The view total is at most 5 seconds behind. A person's own like shows immediately.
  • The like total always equals the like records, exactly, with no double counting on retries.
  • A server crash loses at most the last 1 second of buffered views.

Step 2 of 6: Size it

Show the maths

These numbers are assumptions for the exercise, not facts about a real system:

  • 500 million view events a day across all items, before dedupe, and 20 million like actions a day.
  • One viral item can reach 100,000 view events a second. A single counter node handles about 50,000 increments a second before it becomes a hot spot.
  • There are 200 million items, and 1% of them are hot enough to use 16 shards. A counter row (item id and a number) takes about 16 bytes, and a like record about 24 bytes.
  • 20 API servers take the write traffic, and peak traffic is 10 times the average.

The arithmetic:

View events per second
500M ÷ 86,400 s ≈ 5,787
≈ 5,800/s, 58,000/s at a 10x peak, plus up to 100,000/s from one viral item
Viral item, one counter
100,000/s ÷ 50,000/s per node = 2 nodes; spread over 16 shards: 100,000 ÷ 16
A single counter is over capacity. 16 shards take 6,250/s each
Writes after batching
100,000/s on one item ÷ 20 servers, each flushing once a second
20 increments/s on the shards, 5,000x fewer than 100,000
Counter storage
200M items × 16 B = 3.2 GB; at 16 shards for 1% hot items add 2M × 15 × 16 B ≈ 0.5 GB
≈ 3.7 GB, so all counters fit in memory
Unique-viewer sketch
16,384 registers × 6 bits = 12,288 bytes; error ≈ 1.04 ÷ √16,384 = 1.04 ÷ 128
≈ 12 KB and about 0.8% error, however big the audience
Like records a year
20M × 24 B = 480 MB a day; × 365
≈ 175 GB, which fits on one sharded database

Step 3 of 6: Sketch the path of one request

Step through the diagram at the top of the page to watch a request move through these parts.

Clients talk to the Counter API. It sends views and likes down two different paths, because they have different rules. Views may lag and be approximate. Likes must be exact.

For a view, the API first checks the Count cache for a dedupe key made of the viewer and the item. The key lives for 30 minutes, so a repeat view in the window stops here. A new view goes to the Batch buffer, which adds events up in memory per item and flushes once a second. The flush adds one total to a random row of the View shards, and merges a small unique-viewer sketch into the Unique sketch.

To read, the API asks the Count cache for the totals of a whole page of items. On a miss the cache adds up the item's shard rows and keeps the sum for a couple of seconds. For a like, the API writes to the Likes DB: a row per person and item, plus a counter row, in one transaction. That is why the like total is exact.

Other paths through the system

Read the counts

  1. A feed page asks for the counts of 20 items.
  2. The API reads the view totals from the count cache in one batched call.
  3. On a miss, the cache reads the item's shard rows and adds them up.
  4. The shards return their rows, and the cache keeps the sum for 2 seconds.
  5. The cache returns the totals to the API.
  6. The client shows the counts, which may be a few seconds old.

Like an item

  1. A user taps like. The request carries the user id and the item id.
  2. One transaction inserts the user and item row if it is absent, and adds 1 to a like shard only if the insert happened.
  3. The database reports what changed. A second tap on a liked item changes nothing and gets the same answer.
  4. The client shows the item as liked at once, with the exact like total from the transaction.

Step 4 of 6: Go deep on the hard part

Sharded counters and write batching

InterviewerOne item is getting 100,000 views a second. How do you count them without a hot spot?A single counter row is a bottleneck.

A single counter row is a bottleneck. Every increment must read and write the same number in the same place, so writes queue behind each other. If one node handles about 50,000 increments a second, a viral item at 100,000 a second is twice what it can take. Two fixes work together, and they are worth separating, because they protect against different things.

Shard the counter. Store N rows for the item instead of one, for example 16, keyed by item id and shard number. Each increment picks a shard at random. A read adds up the 16 rows. The write load on one row drops to 100,000 ÷ 16 = 6,250 a second, and the rows can sit on different nodes. The cost is a wider read, so cache the sum for a second or two. Use one shard for ordinary items. Promote an item to 16 shards when its rate crosses a threshold, because more shards means a wider read for no gain.

Batch in memory. Each API server keeps a map from item to a count. A timer flushes the map once a second as one add-N call per item. With 20 servers, a viral item sees 20 increments a second rather than 100,000. That is 5,000 times fewer writes. With in-memory batching, the shards are a safety margin, not a requirement at 20 servers: 20 increments a second fit one row. They matter for the durable-log path below, where every event reaches the store, and for a fleet of hundreds of API servers, whose flushes add up again.

Adds are safe to combine, since addition does not care about order. Batching has a cost: a crash loses up to one second of that server's buffer. For a view count, that is fine, and a graceful shutdown flushes first. If a flush call times out, a retry might count it twice. Tag each flush with the server id and a sequence number and let the store ignore repeats, or accept the tiny error for views.

If you need no loss at all, write each event to a durable log first and let consumers add up the log into shards. It is the same sharding on the other side, with a log in front for safety. It costs more to run, and it is the right move when the count is used for money.

Counting each viewer once, and approximate uniques

InterviewerHow do you count each viewer once, and how do you count unique viewers over time at a low cost?There are two different questions.

There are two different questions. The first is "has this person already counted as a view in the last 30 minutes?" That has an exact answer and a short memory.

The dedupe window. Identify the viewer by user id, or by an anonymous cookie for signed-out visitors. On each view, try to set a key made of the viewer and the item, with a 30-minute expiry, and only count when the set succeeds. A refresh storm then counts once. The memory is bounded: 5,787 views a second × 1,800 seconds is about 10.4 million live keys. At about 50 bytes each, that is around 0.5 GB on average and 5 GB at a 10x peak, spread over the cache cluster.

The second question is "how many different people ever watched this?" Keeping an exact set costs about 8 bytes per viewer. An item with 50 million viewers needs 400 MB for the set alone. Use a HyperLogLog sketch instead.

The idea: hash each viewer id into a number. Use the first 14 bits to choose one of 16,384 registers. In each register keep the longest run of leading zeros seen in the other bits, because a long run is rare, and rarity tells you roughly how many different ids you have met. Combine the registers with a harmonic average to get the estimate. The sketch is 12 KB whatever the audience size, with an error of about 1.04 ÷ √16,384, near 0.8%.

Two properties make it fit this design. Sketches merge by taking the larger value in each register, so each server can keep a small sketch per item and send it with the flush. And an estimate of 1.2 million that is really 1.19 million is invisible on a screen that rounds to "1.2M". The sketch cannot say whether one particular person is in it, or remove a person. For billing or fraud checks, use the raw event log offline instead.

To resist inflation, also count a view only after a minimum watch time, rate limit per client, and drop known bots before the counter.

An idempotent like and an exact total

InterviewerHow do you make likes exact and safe to retry, when views are allowed to be approximate?A like is state, not an event.

A like is state, not an event. A person either likes an item or does not. So store a row per person and item, with a unique key on the pair, and let the row decide.

  • Like: insert the row if it is absent. If it was already there, nothing changes.
  • Unlike: delete the row if it is present. If it was not, nothing changes.

Both are safe to repeat. A double tap, a retry after a timeout and two devices acting at once all end in the same state. The total follows the same rule: change it only if the row changed. In the same database transaction, add 1 to a counter row when the insert happened, or subtract 1 when the delete happened. The counter and the rows cannot disagree, because they commit together.

Likes arrive far more slowly than views, 20 million a day against 500 million. Even so, a viral item may see thousands a second, so the like counter is also sharded. Pick the shard from the user id, such as the user id modulo 16. Then the same person always touches the same shard, so an unlike takes from the shard that the like added to, and no shard goes below zero. Partition the likes table by item id, so the rows and shards of one item are on the same node and the transaction stays on one machine.

To show "you liked this" on a page of 20 items, run one batched lookup of the person's rows for those item ids. For a "things I liked" list, keep a second copy of the rows keyed by user, updated asynchronously.

Why the two totals differ. A like is something the person just did, and a total that ignores it feels broken. So the like response carries the exact new total, and the client shows it at once. A view total that is 5 seconds behind is invisible, and giving it up is what pays for the cheap write path.

Step 5 of 6: Name the trade-offs

Hot counters
Chose
A counter split over 16 rows
Over
A single counter row per item

Writes spread over many rows and nodes, so a viral item does not overload one. The cost is a read that adds up 16 rows, which a short cache hides.

Write path for views
Chose
Add up in memory and flush every second
Over
Write every event straight to the store

The store sees thousands of times fewer writes. The cost is that a crash loses up to a second of views, and the total lags by a few seconds.

Unique viewers
Chose
A HyperLogLog sketch
Over
An exact set of viewer ids

It uses 12 KB per item and merges across servers. The cost is about 0.8% error, so it is not suitable for billing.

Like totals
Chose
A counter updated in the same transaction as the like row
Over
Counting rows when the page loads

The read is one lookup and the total is always exact. The cost is a write that touches two rows, and a shard to spread contention.

Step 6 of 6: Say what breaks next

Follow-up questions an interviewer asks once the design is on the board. Answer each one out loud, then open it to compare.

How would you show the most-viewed items in the last hour?
Keep the same sharded counters per minute, as one row per item and minute. Add up the last 60 minute rows for an item, or have a stream job keep a rolling window. Old minute rows expire on their own.
How do you stop bots from inflating views?
Combine several cheap checks: the dedupe window, a rate limit per client, a minimum watch time before a view counts, and a block list of known bot addresses. For anything that affects money, recount offline from the raw event log and correct the totals afterwards.
What if a shard node fails?
Keep each shard on a replicated node so a replica takes over. Flushes carry an id, so a retry after the failover does not count twice. View counts can also be rebuilt from the event log if the loss was large.
How would you count exactly for billing?
Treat the counters as a display cache only. Write every event to a durable log, dedupe and aggregate it in a batch job, and bill from that result. The slow, exact path and the fast, approximate path then serve different needs.

Free ATS check

See your resume the way an ATS reads it, then fix what it misses.

Drop in a PDF for a score out of 100, every fix that cost a point, and the line it belongs to. It runs in your browser, with no signup for the score.