System design interview
Design a view and like counter
Sharded counters, write batching, unique viewers and idempotent likes.
At a glance
- Foundation
- Sharding, Caching
- Counter API · Batch buffer · Count cache · Likes DB · Unique sketch · View shards
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- A viewer opens an item. The client sends a view event with the viewer id and the item id.
- 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.
- 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.
- Once a second, the buffer flushes. Each item's total is added to one randomly chosen shard row.
- 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
- 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 answerHide 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?
Must the view total be exact, or may it run a few seconds behind and be approximate?
Can a person unlike, and must the like total be exact?
How skewed is traffic across items?
Do we need counts by day, country or device?
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 mathsHide 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
- A feed page asks for the counts of 20 items.
- The API reads the view totals from the count cache in one batched call.
- On a miss, the cache reads the item's shard rows and adds them up.
- The shards return their rows, and the cache keeps the sum for 2 seconds.
- The cache returns the totals to the API.
- The client shows the counts, which may be a few seconds old.
Like an item
- A user taps like. The request carries the user id and the item id.
- One transaction inserts the user and item row if it is absent, and adds 1 to a like shard only if the insert happened.
- The database reports what changed. A second tap on a liked item changes nothing and gets the same answer.
- 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
One 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
How 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
How 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
- A counter split over 16 rows
- 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.
- Add up in memory and flush every second
- 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.
- A HyperLogLog sketch
- 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.
- A counter updated in the same transaction as the like row
- 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.