SYSTEM DESIGN · DESIGN PROBLEMS · COUNTING AND RANKING · HARD · 35 OF 41

Design Top K Trending: Stream Counting, Count-Min Sketch and Sliding Windows

How to find the top K most viewed items over the last minute, hour and day at a hundred thousand events a second: why one heap on one machine fails, partitioning counts by item so local top-Ks merge exactly, two-stage aggregation for viral items, sliding windows from tumbling buckets, count-min sketch for bounded memory, a fast stream path checked by an exact batch path, and what "trending" should mean.

Reported atMetaAtlassian

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 21

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen first. We ingest view events, each with an item, a user, a time and a region. Clients ask for the top K items, with K up to a thousand, over the last minute, hour or day, globally or for one region. Personalised ranking and recommendations are out of scope. This is about counting, at scale.

Now the qualities, and ask about accuracy early, because it changes everything. The list can be a minute behind. The top items must be correct, but small errors near rank one thousand are fine. However, if daily counts feed creator payouts, those numbers must be exact, even if they arrive an hour later. That split will give us two paths.

Functional
  • ingest view events
  • (item, user, time, region)
  • top K items, K ≤ 1,000
  • windows: 1 min, 1 hour, 1 day
  • global and per region
  • out of scope: personalised
  • ranking, the video player,
  • recommendation models
Non-functional
  • fresh: list ≤ 1 min behind
  • reads ≤ 50 ms, very cacheable
  • accuracy: the top of the list
  • must be right; tiny errors
  • near rank 1,000 are fine
  • exact daily numbers for
  • creator payouts (later)
  • survive spikes and node loss

Ask scale

Scale. Ten billion view events a day is a hundred thousand a second, three hundred thousand at peak, and a terabyte of raw data daily. About a hundred million distinct items get views each day. An exact count map is around five gigabytes per window, per region. Reads are only a few thousand a second, and everyone asks the same question, so they cache perfectly.

Back of the envelope
10B view events/day
  10B / 10⁵ s = 100,000 events/s
  peak ×3 ≈ 300,000 events/s
event ≈ 100 B → 1 TB/day raw

distinct items per day ≈ 100M
exact map: 100M × ~50 B ≈ 5 GB
  per window, per region

reads: 5,000 req/s, same answers

Define API

The API is small. Ingest is internal and batched, a few hundred events per call, so we are not paying for one request per view. The read endpoint takes a window, K and a region, and returns ranked items with counts and an as of timestamp, so clients can show how fresh the list is.

API
internal ingest (batched):
POST /v1/events
  [{ item_id, user_id, ts, region }]

GET /v1/trending
  ?window=1h&k=100&region=IN
→ { as_of, items: [
    { item_id, count, rank } ] }

as_of tells clients how fresh

Data model

Data model. Raw events go to object storage as Parquet files partitioned by hour: cheap, and the source of truth for replays. The stream keeps per-minute counts as working state. Results are tiny: for each window, region and time, a ranked list of a thousand items in a key-value store. Exact daily counts land in a warehouse table.

Data model
raw events → object storage
  Parquet, partitioned by hour

per-minute counts (stream state)
  (minute, item_id) → count

top-K store (Redis / KV)
  key topk:1h:IN:2026-10-24T14:05
  value [(item, count) × 1,000]

exact daily counts → warehouse

High-level design

The naive design: one server with a map from item to count and a min heap of size K. It breaks fast. Three hundred thousand updates a second is too much for one machine, the maps are gigabytes per window, a crash loses all state, and a sliding hour means re-summing everything every minute. So we distribute it.

The real design. Ingest writes events to Kafka keyed by item id, so every view of one item lands on the same partition. A stream job, Flink for example, counts per partition per minute and keeps a local top thousand. A merger combines all the local lists into a global top thousand per window and writes it to the store. The API just reads it.

This detail wins points. The merge is exact because of the partition key. Each item is counted in exactly one place, so its local count is its full count, and the global top K must appear in some local top K. If events were spread randomly, an item could be number one thousand and one everywhere yet first overall, and the merge would miss it.

The naive version
  • one server:
  • counts[item] += 1
  • keep a min-heap of size K
  • breaks:
  • 300K updates/s on one CPU
  • 5 GB+ per window in memory
  • one crash loses the window
  • sliding window = re-sum all
Why the merge is exact
  • keyed by item_id:
  • item 77 counted in exactly
  • one partition → its count
  • there is the full count
  • → top K of all = top K of
  • the local top Ks
  • keyed randomly:
  • item 77 split across 64
  • partitions → each local list
  • can miss it → wrong answer

Deep dive: hot items

Deep dive one: hot items. Keying by item makes a viral video a hot partition. If one item gets eighty thousand views a second, partition twelve does nearly all the work while sixty-three others idle, and its consumer lag climbs. The very thing we want to measure, a spike, is what breaks the pipeline.

The fix is two-stage aggregation. Each ingest server sums its own events for about a second and sends item plus count instead of raw events. Eighty thousand views a second across twenty ingest servers becomes twenty small messages a second. The keyed counters then add partial sums. Raw events still go to the lake for the exact path.

Two-stage aggregation
  • stage 1, at ingest:
  • each server sums its own
  • events for ~1 s
  • sends (item, +count)
  • 80,000 events/s for item 77
  • → 20 ingest servers send
  • 20 messages/s
  • stage 2: keyed counters add
  • the partial sums

Deep dive: windows

Deep dive two: sliding windows. Count in tumbling one-minute buckets. The last hour is the sum of the last sixty. Each minute, add the newest bucket and subtract the one that just expired, so the rolling total updates in one pass. The day uses twenty-four hour buckets. Crucially, keep per-item counts for each bucket, not just each bucket's top list.

Why keep full counts? Because rankings do not add up. An item that sits just outside the top thousand every single minute can be in the top hundred for the hour, yet it never appears in any minute's list. So windows add counts first, and only then rank. Ranking is the last step, never the thing you sum.

Sliding window from buckets
tumbling 1-min buckets per item

last hour at 14:05 =
  buckets 13:06 … 14:05 (60)

every minute:
  hour[item] += new bucket
  hour[item] −= bucket from 61 ago

day: 24 one-hour buckets
keep counts, not just top K
Why not sum minute top Ks?
  • item A: #2 every minute
  • → #1 for the hour
  • item B: rank 1,001 every
  • minute, 60 minutes in a row
  • → could be top 100 hourly
  • → never in any minute list
  • rule: windows add counts,
  • then rank

Deep dive: memory

Deep dive three: memory. If a hundred million items per bucket is too much, use a count-min sketch. Four rows of about a million counters, sixteen megabytes, no matter how many items. Each event increments one counter per row. The estimate is the minimum of the four. It can only overcount, and by a bounded amount. Keep a small heap of the current top candidates beside it.

Sketches combine well. Two count-min sketches of the same shape add cell by cell, so the minute-bucket window trick still works. The weaknesses: small items look a bit bigger than they are, and a sketch cannot list its items, so keep a heap of candidates. Another option is the Space-Saving algorithm, which tracks a fixed set of counters and replaces the smallest.

Count-min sketch
d = 4 rows × w = 2^20 counters
4 bytes each → 16 MB, any # items

add(x): row i: C[i][hash_i(x)]++
est(x): min over the 4 rows

only overcounts, by at most
  ≈ 2.7 × N / w (high prob.)
N = 10B/day → at most +26,000

pair with a heap of K candidates
Sketch trade-offs
  • + fixed memory per bucket
  • + two sketches add cell by cell
  • → windows still work
  • − small items look bigger
  • − cannot list items by itself
  • → keep a candidate heap
  • alternative: Space-Saving
  • keeps K counters, evicts min

Deep dive: accuracy

Now the accuracy split from the requirements. The stream path is fast and approximate, good enough for a trending page. A batch job runs every hour over the raw events in the lake, counts exactly with deduplication, and overwrites the stored lists for closed hours. Payouts read only the exact numbers. This fast-plus-exact pairing is often called the lambda architecture.

One more question to ask: does trending mean most viewed, or rising fastest? Raw counts show the same giant items every day. Trending usually compares the last hour to that item's normal rate over the past week, with smoothing so a jump from one view to four is ignored. And count each user once per item per window, so bots cannot buy a spot.

Popular vs trending
  • most viewed: raw count
  • → same giants every day
  • trending: growth vs baseline
  • score = last hour views /
  • (avg hourly views, 7 days
  • + smoothing)
  • filter bots: dedupe per
  • user per item per window

Trade-offs

Failure handling. Stream counters checkpoint their state, so a dead node restores and replays Kafka from the saved offset. If the merger is late, the API keeps serving the previous list and the as of field says so. Monitor consumer lag, partition skew, end-to-end freshness, and the gap between stream and batch counts, which tells you how wrong the fast path is.

At ten times the traffic, add Kafka partitions, pre-combine for a few seconds instead of one, and switch minute buckets to sketches. For many regions, compute each region in parallel and build the global list from regional counts, not regional rankings, for the same reason as before. Reads stay trivial: the answer can sit in a CDN for thirty seconds.

Failures and monitoring
  • counter node dies → Flink
  • restores from checkpoint,
  • replays Kafka from offset
  • merger late → API serves the
  • previous list (as_of shows it)
  • monitor: consumer lag,
  • partition skew, end-to-end
  • freshness, stream vs batch
  • difference per hour
At 10x
  • 3M events/s:
  • more Kafka partitions
  • longer pre-combine (2–5 s)
  • sketches for minute buckets
  • more windows and regions:
  • compute regional lists in
  • parallel, global from the
  • regional counts
  • reads: CDN cache, 30 s TTL

Say it like this

In the interview: partition counts by item, so merging local top K lists is exact. Pre-combine at ingest so viral items do not create hot partitions. Build windows from one-minute buckets, add counts and only then rank. Use a count-min sketch and a candidate heap when memory is tight. Run a fast stream path for the page and an exact batch path for anything that touches money.

Interview summary
  • partition by item_id
  • → local top K merge is exact
  • pre-combine at ingest
  • → viral items do not melt
  • 1-min buckets; add then rank
  • count-min sketch + heap
  • when memory is tight
  • stream = fast, approximate
  • batch = exact, for money
  • trending = growth vs baseline

Interview questions

Why can you merge per-partition top-K lists into an exact global top K?

Only when events are partitioned by item ID, because then each item's full count lives in one partition, so any item in the global top K must also be in its partition's local top K.

How do you handle a viral item that overloads its partition?

Use two-stage aggregation: ingest servers pre-combine counts for a short interval and send partial sums, so millions of events become a handful of messages before they reach the keyed counters.

How do you compute a sliding one-hour window efficiently?

Count in one-minute tumbling buckets and maintain a rolling total by adding the newest bucket and subtracting the expired one, keeping per-item counts rather than per-bucket rankings.

Why can't you combine the top K of each minute to get the top K of the hour?

An item can rank just below K in every minute and still be in the hourly top K, so it never appears in any minute list; you must add counts across buckets first and rank last.

What is a count-min sketch and what error does it have?

A d by w array of counters with one hash per row; each item increments one counter per row and its estimate is the minimum across rows. It never undercounts and overcounts by at most about e times N over w with high probability.

How would you make the counts exact for payouts?

Keep raw events in object storage and run a periodic batch job that deduplicates and counts exactly, overwriting the approximate stream results for closed windows; payouts use only the batch output.

What is the difference between popular and trending?

Popular ranks by raw count, while trending ranks by growth relative to an item's own baseline, such as the last hour's views divided by its average hourly views over a week, with smoothing for small items.

How does the system recover if a stream counter crashes?

The stream processor checkpoints its state with the Kafka offsets, so a replacement restores the last checkpoint and replays events from that offset, while the API keeps serving the previous list with its as_of time.