Design Top-K Trending
Find the most viewed videos of the last hour, day and month: exact versus count-min sketch counts, sliding windows, merging partitions, precomputed reads.
On this page
A video site has a "Trending" tab: the 100 most viewed videos in the last hour, the last day and the last month. Behind the tab is a question that sounds like a single ORDER BY and is not: billions of views a day, hundreds of millions of distinct videos, windows that slide forward every minute, and a page that millions of people load.
Interviewers like it because it tests whether you do the arithmetic before reaching for a clever structure: sometimes exact counting fits, and sometimes you need a sketch and an honest statement of its error. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.
Clarify the problem
Design a system that shows the top K most viewed videos.
Over which time ranges, and how big is K?
The last hour, the last day and the last month. K is at most 100.
Are those sliding windows, "the 60 minutes up to now", or calendar ones, "since 10:00"? And how fresh must each list be?
Sliding. The hour list should move every minute or so; the month list can be an hour old.
Does the order have to be exact, or is "very close" fine?
Close is fine as long as a clearly top video never goes missing.
Then reads will be one lookup of a precomputed list, and I'll pick exact or approximate counting per window by its memory cost. I'll take a clean stream of view events as given; defining and filtering views happens upstream.
- Functional: return the top K (K ≤ 100) videos by views over the last hour, day and month, with their view counts.
- Non-functional: the hour and day lists refresh every minute and the month list every hour; reads return in under 50 ms at any load; a video with clearly more views than the 100th is never missing; counts within about 5% are fine for the month.
- Out of scope: defining a view, fraud filtering, personalised trending, arbitrary time ranges.
Estimate the scale
The scenario: 5 billion views a day. In a typical hour about 30 million distinct videos get a view, about 150 million in a day and 500 million in a month. A counter entry (video id, count and hash-map overhead) takes about 50 bytes.
- Views: 5 × 109 ÷ 86,400 s ≈ 58,000 a second on average; at 3x peaks, about 175,000 a second.
- Hour window, exact: to slide by the minute, keep one count map per minute (assume 2 million distinct videos a minute) plus running totals: 60 × 2 × 106 × 50 B + 30 × 106 × 50 B = 6 GB + 1.5 GB = 7.5 GB.
- Day window, exact: one map per hour: 24 × 30 × 106 × 50 B + 150 × 106 × 50 B = 36 GB + 7.5 GB = 43.5 GB.
- Reads: say the trending page peaks at 20,000 loads a second. Every one of them asks the same three questions.
How much memory do exact counts for a sliding 30-day window take?
- One count map per day, so the oldest day can be subtracted: 150 million distinct videos a day
- Running totals for the month: 500 million distinct videos
- About 50 bytes per entry
Show the working
- Daily maps: 30 × 150 × 106 × 50 B = 225 GB
- Running totals: 500 × 106 × 50 B = 25 GB
- Total: 250 GB
Estimate 250 GB (anything from 80 GB to 800 GB is a good answer)
7.5 GB for the hour spread over 32 tasks is trivial. 250 GB is not, which is why the month gets a different answer below.
Write the read endpoint. What parameters does it take, and what does it refuse?
Compare with a model answer
One GET with a window name and a limit up to 100. It refuses anything that is not precomputed, such as an arbitrary time range or K = 1,000, because every allowed request must be a single lookup.
Define the API
GET /v1/trending?window=hour&limit=50
200 { "window": "hour", "as_of": "2026-11-14T10:06:00Z",
"items": [ { "video_id": "V7", "title": "...", "views": 48210 }, ... ] }
400 Bad Request window is not hour, day or month, or limit > 100
Cache-Control: max-age=30
View events (internal, from the video service to the log):
{ "video_id": "V7", "count": 3, "second": "10:05:12" } -- pre-summed per server per secondThe decision worth arguing is what the API refuses. "Top 50 for 14:00 to 15:30 last Tuesday" turns a lookup into a scan over billions of events. Three windows and K ≤ 100 keep every read to one key; ad hoc ranges belong in a warehouse, a different system.
View events are pre-summed: one event per video per server per second. A viral video watched 20,000 times a second on 500 servers becomes at most 500 events a second.
What state does the counting side keep for the hour window, and what does the read side store?
Compare with a model answer
The counting side keeps per-minute count maps and running totals in memory, partitioned by video id and checkpointed. The read side stores one small, finished list per window in a key-value store: about 100 entries, already sorted, with titles attached.
Model the data
Counter task state (one task per log partition, 32 partitions)
minute_buckets : ring of 60 maps video_id -> views in that minute
hour_totals : map video_id -> sum of the 60 buckets
hour_buckets : ring of 24 maps (same idea for the day)
day_sketches : 31 daily count-min sketches + 1 running month sketch
candidates : video ids whose month estimate passed a floor
checkpoint : state + log offsets, written to object storage every minute
Results (key-value store, about 10 KB per value)
key "trending:hour" | "trending:day" | "trending:month"
value { as_of, version, items: [ {video_id, title, thumbnail, views} x 100 ] }The counting state is a stream processor's keyed state, kept next to the computation and snapshotted for recovery; nothing on the read path touches it. The results are three keys read constantly and written once a minute: a key-value store or an in-memory cache with a durable copy. Titles are attached when the list is built, so a read never joins.
Sketch the path from a view to the trending page. Where are counts kept, where do the partitions' answers meet, and what does a page load touch?
Compare with a model answer
Views go into a log partitioned by video id. Counter tasks keep sliding-window counts and emit their local top 100 every minute. A merger combines the 32 lists into the global top 100 and writes it to a results store. The page reads one key, through a CDN.
High-level design
- V7 this minute
- 0
1At 10:05:12 one video server saw 3 views of V7 in the last second. It sends one event with
count = 3to the log partition for V7, partition 12.- V7 this minute
- 3
- V7 hour total
- 48,820
2Counter task 12 adds 3 to V7 in the 10:05 bucket and in the running hour total: two hash-map updates.
- V7 hour total
- 48,210
3At 10:06:00 the window slides. The 09:06 bucket falls out: the task subtracts each of its counts from the totals (V7 had 610 then) and reuses its slot for 10:06.
- V7 hour total
- 48,210
4The task scans its 1 million hour totals with a 100-entry min-heap and sends its local top 100 to the merger. V7 is its number 1.
- V7 global rank
- 4
5The merger has 32 lists, 3,200 entries. It keeps the best 100. The result is exact, because each video's whole count lives in one partition.
- V7 global rank
- 4
6It fetches titles and thumbnails for the 100 videos and writes
trending:hourwith version 10:06, only if the stored version is older.- page reads
- 1 key
7A user opens the Trending tab. The CDN has a copy under 30 seconds old; on a miss the API reads one key.
- V7 global rank
- 4
8Counter task 12 crashes at 10:06:40. At 10:07 the merger gets only 31 lists. It reuses task 12's 10:06 list instead of dropping its videos, so V7 does not vanish from trending.
- V7 hour total
- 48,610
9A replacement task loads the 10:06 checkpoint (state and log offsets taken together) and replays partition 12 from that offset. About 40 seconds of events later, its counts are exact again.
- stale writes applied
- 0
10Meanwhile a merger that paused during a long garbage collection wakes up and tries to write its old 10:05 list. The store holds version 10:06, so the conditional write is refused.
Deep dives
Windows: tumbling or sliding
A tumbling window is a fixed block, 10:00 to 11:00. At 10:01 its list is built from one minute of data, and a video that peaked at 9:55 vanishes at 10:00 sharp. A sliding window, the 60 minutes up to now, moves smoothly, but done literally it must forget each view exactly 60 minutes later.
The practical answer is a sliding window built from small tumbling buckets: 60 one-minute buckets for the hour. Each minute, add the newest bucket to the running totals and subtract the oldest; the work is proportional to one minute's distinct videos, not the hour's. For the day, use 24 hourly buckets plus the current partial hour, so the day list also refreshes every minute; its window is 23 to 24 hours long, which nobody notices on a trending page.
Merging partitions into one top K
Counting is split over 32 partitions. Each finds its local top 100; the merger picks the best 100 of the 3,200. Whether that is right depends entirely on how events were partitioned.
- By video id (our choice): every view of V7 lands in partition 12, so partition 12 holds V7's true total. If V7 is in the global top 100, at most 99 videos anywhere beat it, so at most 99 in its own partition do, and it is in partition 12's local top 100. The merge is exact.
- By server or at random: V7's views are spread over all 32 partitions. It could rank 101st in every one and still be first overall. Merging local top lists is then simply wrong, and you would need to ship every count to the merger or do a second exact pass on candidates.
The cost of partitioning by video is skew: a viral video loads one partition. Pre-summing per server per second cuts that to a few hundred events a second. The ad click aggregator shows the salted two-stage fix for when that is not enough.
Exact counts or a sketch
For the month, exact counts take 250 GB, mostly the long tail. A count-min sketch trades a small, one-sided error for a fixed amount of memory. It is a grid of counters, d rows by w columns, with one hash function per row. To add a video, hash it once per row and add to that cell in every row. To estimate its count, read those same d cells and take the smallest. Other videos that hash into the same cell can only add to it, so the estimate is never too low; taking the minimum picks the row where collisions hurt least.
- sketch
0 1 2 3 4 5 6 7 h1 0 0 0 0 0 0 0 0 h2 0 0 0 0 0 0 0 0 h3 0 0 0 0 0 0 0 0 - total views
- 0
1An empty sketch: 3 rows, each with its own hash function, 8 counters per row.
- sketch
0 1 2 3 4 5 6 7 h1 0 3 0 0 0 0 0 0 h2 0 0 0 0 0 3 0 0 h3 0 0 3 0 0 0 0 0 - total views
- 3
2V7 gets 3 views. Its hashes are columns 1, 5 and 2, so add 3 to one cell in each row.
- sketch
0 1 2 3 4 5 6 7 h1 0 3 0 0 2 0 0 0 h2 0 0 0 0 0 5 0 0 h3 0 0 3 0 0 0 0 2 - total views
- 5
3V2 gets 2 views, hashing to columns 4, 5 and 7. In row h2 it lands on the same cell as V7: a collision, 3 + 2 = 5.
- sketch
0 1 2 3 4 5 6 7 h1 0 4 0 0 2 0 0 0 h2 1 0 0 0 0 5 0 0 h3 0 0 3 0 0 0 1 2 - total views
- 6
4V9 gets 1 view, hashing to columns 1, 0 and 6. It collides with V7 in row h1: 3 + 1 = 4.
- sketch
0 1 2 3 4 5 6 7 h1 0 4 0 0 2 0 0 0 h2 1 0 0 0 0 5 0 0 h3 0 0 3 0 0 0 1 2 - V7 estimate
- 3
- V7 true
- 3
5Estimate V7: its cells hold 4, 5 and 3. Two are inflated by collisions; the minimum, 3, is exactly right.
- sketch
0 1 2 3 4 5 6 7 h1 0 4 0 0 2 0 0 0 h2 1 0 0 0 0 5 0 0 h3 0 0 3 0 0 0 1 2 - V4 estimate
- 2
- V4 true
- 0
6Estimate V4, which was never viewed. It hashes to columns 1, 5 and 7, which hold 4, 5 and 2. Every one of its cells collided, so the sketch says 2 when the truth is 0.
- sketch
0 1 2 3 4 5 6 7 h1 0 4 0 0 2 0 0 0 h2 1 0 0 0 0 5 0 0 h3 0 0 3 0 0 0 1 2 - counters
- 24
- videos
- any number
7That is the whole trade: 24 counters for any number of videos, estimates that are never low and sometimes high. Wider rows mean fewer collisions; more rows mean less bad luck.
The guarantee is precise: with width w and depth d, each estimate is at most the true count plus (e ÷ w) × N, where N is the total of all counts added and e ≈ 2.718, with probability at least 1 − e−d. Each counter task keeps sketches for its own partition only, since every view of a video lands there. A month is 5 × 109 × 30 = 1.5 × 1011 views, so one task sees N ≈ 4.7 × 109. With w = 218 ≈ 262,000 and d = 5, an estimate is high by at most 2.718 ÷ 262,144 × 4.7 × 109 ≈ 48,600 views, with probability 1 − e−5 ≈ 99.3% for any one video. Assume the 100th video of a month has at least a million views: the error is under 5% of the counts that matter. Each sketch is 5 × 218 × 8 B ≈ 10.5 MB.
A sketch answers "how many for this video?" but cannot list the top ones, so pair it with a candidate set and a heap. Each task adds every view to today's sketch and to a running month sketch, then asks the month sketch for that video's estimate. If it passes a fixed floor, here 500,000 (half the assumed 100th place), the video joins the candidates. An estimate is never low, so any video whose true month count passes the floor joined on its last view. At midnight the task subtracts the day that fell out from the month sketch; cells add and subtract one by one when all sketches share hashes and width. Every hour it re-estimates the candidates, drops those under the floor, and sends its best 100, found with a min-heap, to the merger.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Exact counts in the stream | Exact, and the same code as the hour and day. | 250 GB of state to checkpoint and restore, almost all of it the long tail. | Distinct items are few, or exact counts are a requirement. |
| Count-min sketches + candidate heap Usual default | 32 sketches of 10.5 MB per task, about 11 GB in all, 23 times less. Fixed size however many videos exist. | Counts may be high by about 48,600, rarely more. The candidate floor must stay well under the 100th place. | "Close" is acceptable and the key space is huge. Our choice for the month. |
| Hourly batch over daily counts | Exact. Daily totals on disk in a warehouse; one group-by, sum, top 100. | A second system and code path; scanning 30 × 150 million rows every hour. | You already run a warehouse and an hour of lag is fine. |
Precomputing so a read is one lookup
20,000 page loads a second ask one of three questions whose answers change once a minute; computing per request would redo the same work 1.2 million times a minute. The merger writes each finished list under one key, and the page reads it through a CDN with a 30-second lifetime. A slow pipeline means a slightly older list, never a slow page.
Each write carries a version (its minute) and succeeds only if newer than the stored one, or a merger that stalls and wakes late overwrites a fresh list with an old one, as in the tracer.
Bottlenecks and failures
At 10x, 1.75 million views a second and 10x the distinct videos, the hour state grows to 75 GB: go from 32 to 320 partitions, about 234 MB per task. Exact day counts reach 435 GB, the point where the day should move to sketches too. The merger handles 320 lists of 100 a minute easily, and reads do not change at all.
| When this dies | What happens | What you do |
|---|---|---|
| A counter task | Its partition's counts stop. | The merger reuses its last list; a new task restores the checkpoint and replays the log. |
| The merger | Lists stop refreshing. | Readers keep the last version. Run a standby; conditional writes stop a stale one from overwriting. |
| The results store | The API cannot fetch lists. | The CDN and the API's memory serve the last copy for minutes; replicate the store. |
| The view log | Video servers cannot send counts. | They buffer per-second sums locally; lists freeze, then catch up. Playback is unaffected. |
| The metadata store | New lists lack titles. | Reuse titles from the previous list; fetch only new entries. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Runs GROUP BY video ORDER BY count LIMIT 100 over a views table; with prompting, caches the result. |
| Mid | Counts in a stream processor with windows, keeps a heap for the top K, and precomputes the results. |
| Senior | Asks sliding or calendar and exact or close, does the memory arithmetic per window, partitions by item so the merge is exact and says why, uses a sketch only where the numbers demand it and states its error bound. |
Why not use a count-min sketch everywhere?
Because exact is affordable for the hour and the day: 7.5 GB and 43.5 GB spread over 32 tasks. The month needs 250 GB exact, almost all of it the long tail, so there each task keeps daily sketches of 10.5 MB, about 11 GB in all. With a width of 218 and depth 5 over a task's share of views, an estimate is high by at most about 48,600 views with 99.3% probability, under 5% of what a top-100 video gets in a month.
What does that error look like on the page?
Counts are shown rounded, like "1.2M views", so nobody sees it. Two videos with nearly equal counts near the 100th spot might swap. A clearly top video can't drop out because of the sketch, since its estimate is never low.
Follow-up questions
What changes for the most searched terms?
Normalise queries first. Distinct terms are far more numerous and most appear once, so exact maps are mostly waste: use a sketch plus heap for the day window too.
Trending should mean rising, not just big. How?
Rank by this hour's views divided by the video's usual hourly rate, with a minimum count so a jump from 2 to 20 does not win. Only the score changes.
How do you add per-country lists?
Make the bucket key (country, video). Memory grows with the pairs that actually occur, and the results store gets one key per country and window, still a single read each.
How do you stop people gaming the list?
Count only views that pass upstream checks, cap what one account or device adds per video per hour, and let moderation remove a video from candidates.
Why not a sorted set in an in-memory store?
For one window it works, as in Design a Leaderboard. Here every view would be a network write at 175,000 a second, and expiring old views from a sorted set still needs per-minute buckets. Counting inside the stream task avoids both.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".