Design a top-K trending service
Open in the interactive app: practice, quiz, mock interview →In one minute
- Partition the stream by item ID. If every event for an item lands on one shard, each shard's local top K is exact for its items, and the global top K is just the top K of the shards' lists. Partition any other way and merging per-shard lists gives wrong answers.
- Decide per window whether you need exact or approximate. Minute and hour leaderboards tolerate a small, bounded error and can use a count-min sketch with a heap. Daily and all-time charts that feed payouts or editorial decisions come from an exact batch job.
- Build long windows from short buckets. Keep counts per minute, slide an hour by adding the newest minute and subtracting the oldest, and roll minutes into hours and days. Never recount the raw stream for every window.
- Serve precomputed snapshots. Readers never trigger computation. A merger publishes the top K for each window and scope as a small blob, and the read path is a cache lookup.
- Pre-aggregate at the edge to tame viral items. One video can draw a large share of traffic for an hour. Summing counts for a second in each ingest node turns thousands of events into one.
Requirements
Functional
- Return the top K (K up to 1,000) most viewed items for the last 1 minute, 1 hour, 1 day and all time.
- Scopes: global, per country and per category.
- Each entry includes the item ID and its count for the window.
Non-functional
- Results for a minute window available within about 10 seconds of the minute ending.
- Read latency under 50 ms at p99, at tens of thousands of reads per second.
- Minute and hour lists may be approximate within a stated bound. Daily and all-time lists must be exact.
- Ingest must not lose events at peak, and a counter-node crash must not double count.
Out of scope: personalised ranking, spam and bot filtering (assume an upstream filter), and arbitrary ad hoc time ranges.
Back-of-envelope
- Events: assume 10 billion view events per day. 1e10 / 86,400 ≈ 116k events/s on average, and about 3× that at peak, so plan for 350k/s.
- Event size: about 50 B (item, user hash, timestamp, country, category), so peak ingest is 350k × 50 B ≈ 17.5 MB/s. Raw storage is 1e10 × 50 B = 500 GB/day, about 900 TB over 5 years uncompressed, much less in Parquet.
- Distinct items: perhaps 1 billion items exist, but only tens of millions receive a view in a given hour. Assume 50M distinct items per hour.
- Exact hourly state: 50M items × (8 B ID + 8 B count + ~40 B hash map overhead) ≈ 2.8 GB. Spread over 32 counter shards, that is about 90 MB each. Exact counting per hour is affordable, which is worth noticing before reaching for sketches.
- Per-scope state: 200 countries × 30 categories multiplies scopes. Exact counts for every (scope, item) pair blow up to tens of GB per hour, and that is where a sketch pays for itself.
- Count-min sketch sizing: for error ε·N with probability 1 − δ, width w = ⌈e/ε⌉ and depth d = ⌈ln(1/δ)⌉ (Cormode and Muthukrishnan). With ε = 1e-5 and δ = 0.01: w ≈ 272k, d = 5, so 1.36M counters × 4 B ≈ 5.4 MB per sketch. In an hour with N = 1.5e9 views per shard group, the error bound is 15k views, well below the count of anything in a top 1,000.
- Reads: maybe 20k reads/s of snapshots of at most 1,000 × 16 B ≈ 16 KB; most readers want only the first 10 to 100.
API
GET /v1/top?window=1h&scope=country:BR&k=100
-> 200 {
"window": "1h",
"scope": "country:BR",
"as_of": "2026-10-15T12:34:00Z",
"exact": false,
"error_bound": 15000,
"items": [{"item_id": "v_8842", "count": 1294021}, ...]
}
POST /v1/events (internal, batched)
{"events": [{"item_id": "v_8842", "ts": 1760531640123, "country": "BR", "category": 7}, ...]}
-> 202exact and error_bound are part of the contract, so a product team knows whether a number can be shown on a public chart.
Data model
Kafka topic views, keyed by item_id. Kafka's default partitioner hashes the key, so every event for an item goes to the same partition. This is the decision that makes exact merging possible.
Counter state per shard (in the stream processor, checkpointed):
minute_counts[minute][item] → countfor the last 60 minutes, so the hour can be slid.hour_running[item] → count, updated by adding each new minute and subtracting the expiring one.- One count-min sketch and one candidate heap per (scope, window) for the long-tail scopes.
Snapshot store (Redis or a replicated key-value store): key topk:{window}:{scope}:{bucket_start}, value a compact sorted list of (item_id, count) plus metadata. The key includes the bucket, so a reader can ask for the previous minute and writers never overwrite a list a reader is paging through.
Batch tables (warehouse): daily_item_counts(dt, scope, item_id, views), partitioned by dt and clustered by scope. The all-time table is item_totals(item_id, views) updated by adding each day's partition.
High-level design
flowchart LR
C[Clients] --> I["Ingest API (1s combiner)"]
I -->|"key = item_id"| K[("Kafka views")]
K --> W["Counter shards: minute buckets, sketch + heap"]
W -->|"per-shard top K"| P[("Kafka topk.partials")]
P --> M["Merger"]
M --> S[("Snapshot store")]
K --> A["Archiver"] --> L[("Object store")]
L --> B["Daily exact batch"]
B --> S
Q["Query API"] --> S
R[Reader] --> QWrite path. Clients send view events to the ingest tier. Each ingest node sums events per (item, scope) for one second and emits one record with a count. Records go to Kafka keyed by item ID. Counter shards consume their partitions, add counts into the current minute bucket, update sketches for the per-scope windows, and keep a heap of candidates. When a minute closes (by event time, with a few seconds of grace), each shard emits its local top K for each window and scope to topk.partials.
Merge path. The merger collects one partial list per shard per window. Because items are partitioned, no item appears in two lists, so the global top K is the top K of the concatenation. It writes the snapshot and moves an alias such as topk:1h:global:latest to it.
Read path. The query API reads the latest snapshot for the window and scope, slices the first k entries and returns them. A local in-process cache with a 1-second TTL absorbs the hottest keys.
Batch path. Nightly, a Spark job counts the archived day exactly per scope and writes the daily and all-time tables and snapshots, which replace the streaming daily list.
Deep dives
1. Exact counting versus sketches
Exact counting with a hash map is the simplest correct answer, and the arithmetic says it fits for the global scope (about 90 MB per shard per hour). It stops fitting when scopes multiply: 6,000 country and category combinations each with their own long tail.
For those, each shard keeps a count-min sketch and a min-heap of size K. On each event the shard adds to the sketch, reads the item's estimate (the minimum over d rows), and offers it to the heap: if the item is already a candidate, update it; if the heap has room, insert; otherwise replace the heap minimum only if the new estimate is larger. The sketch never underestimates, so a true heavy hitter cannot be missed because of the sketch. It can only be overestimated by at most ε·N with high probability.
I also considered Space-Saving (Metwally et al.), which keeps exactly m counters and, when full, evicts the smallest and lets the newcomer inherit its count. It has a tighter guarantee per byte for top K and no hashing. A common belief is that it cannot be merged; in fact Space-Saving and Misra-Gries summaries are equivalent and both merge with no loss in their error bound (Agarwal et al., PODS 2012), which is why Apache DataSketches ships a mergeable frequent-items sketch (DataSketches). What these counter summaries cannot do is subtract: you cannot remove the minute that just slid out of the hour. A plain count-min sketch is linear, so the expiring minute's counter array can be subtracted element-wise, which suits a sliding hour built from minute buckets. Redis's built-in Top-K is a third option: it uses HeavyKeeper, which decays the counts of small flows so heavy ones keep their slots, plus a min-heap of K items (Redis docs). Since Redis 8 it ships in core Redis rather than as a separate Redis Stack module (Redis). I chose exact maps for global scopes and sketch plus heap for per-scope windows, with the batch job as the exact backstop.
2. Windows and the merge
A sliding hour cannot be computed by merging 60 per-minute top K lists. An item that ranks 1,001st in every minute could be in the hourly top 1,000, and it would appear in none of the minute lists. So each shard keeps the full minute counts for the last 60 minutes and maintains hour_running incrementally: add the new minute's map, subtract the minute that falls out. Its heap is rebuilt from hour_running in O(n log K) once per minute, which for 1.5M items per shard is tens of milliseconds.
Across shards, the merge is exact only because of item partitioning. If events were spread by a random key, an item's count would be split across shards, and the top K of per-shard top Ks is wrong. The fix there is either to re-key by item before counting, or to ask every shard for its top K' (K' several times K) and accept an approximation. The day window is not slid in the stream at all. It is computed by the batch job and, for freshness, approximated in the stream by summing hourly sketches.
3. Viral items, skew and serving
Because partitioning is by item, a viral video sends all its events to one partition. At 350k events/s, an item with 5% share is 17.5k events/s, which one consumer handles. At 30% share during a live event it is 100k/s, and that partition lags while the rest are idle. The one-second combiner in the ingest tier fixes this: with 200 ingest nodes, the hottest item produces at most 200 records per second into Kafka, regardless of its view rate.
Serving is easy once results are precomputed: snapshots are kilobytes, held in the query API's memory and refreshed when the alias moves.
Variant: A scalable like counter
A like counter looks like top-K with K = 1, but the requirement flips from "approximately right, ranked" to "exactly right, per item, forever". A view event may be counted twice with little harm, because the batch job later corrects it. A like may not, because the number sits beside a heart the viewer toggles, and users notice an off-by-one. That one change rewrites the design.
The fact is an edge, not an event. Store one row per (user, post) with a state and a version, partitioned by user. A like is a conditional write that reports an affected row only when the state flips and the version is newer. Retries, double taps and an unlike arriving before its like all produce no delta. Only a real flip emits a +1 or -1, and the delta is published from the database change stream (CDC or an outbox row), so a crash between commit and publish cannot lose it. Top-K counts requests; this design counts state changes.
Numbers shift too. Assume 500M daily users at 20 likes each: 10B likes a day, about 116k/s on average and 350k/s at peak. Reads dominate at roughly 10 to 1, because every post view shows a count and the viewer's own state, batched 20 posts at a time. The expensive storage is the edges (about 60 B a row, 600 GB a day), not the counters.
Aggregation is per key, with exact checkpoints. The log is keyed by post, so one aggregator owns each counter and folds a second of deltas into one write; a post taking 50,000 likes a second costs one write a second. The aggregator commits its log offset in the same transaction as the counter updates, so a restart neither skips nor repeats deltas. Top-K gets the same property from Flink-style checkpoints but tolerates sketch error, which this design cannot. If one post outgrows a single partition, salt its key into sub-counters that readers sum, as in Firestore's sharded counters (Firestore docs), which trade extra read cost for write throughput.
Serving adds read-your-writes. The aggregator sets the cache to the committed total instead of incrementing it, which is idempotent. The count may lag a few seconds, but the heart comes from the edge row, and the read path adds the viewer's own unapplied delta so the number never jumps back. A recount from the edges, sampled nightly, catches drift, because a derived counter eventually disagrees with its source.
Down the stack
Layer 1The contract
- A minute window's top K is published within 10 seconds of the minute's end plus grace, and an hour window's within 15 seconds.
- For sketch-based lists, every reported count c satisfies true ≤ c ≤ true + ε·N with probability at least 1 − δ, and the response states the bound. An item whose true count beats the K-th true count by more than ε·N will, with that same probability, be in the list once its next event arrives, because its estimate can only grow.
- Daily and all-time lists are exact with respect to the archived events after the batch job completes, and are labelled
exact: true. - Counts reflect each Kafka record once: counter state and consumed offsets are checkpointed together, using the asynchronous barrier snapshots Flink is built on (Carbone et al.).
Layer 2The mechanism
- Partitioning. For a keyed record the Kafka producer picks the partition from a hash of the key modulo the partition count (Kafka producer configs). Adding partitions changes that mapping for new records, and Kafka does not move existing data (Kafka operations docs), so an item's counts would briefly live on two shards. The count is fixed up front (say 128) and over-provisioned.
- Count-min update. For row i, the column is
h_i(item) mod w, computed from two base hashes ash1 + i·h2(the Kirsch-Mitzenmacher trick), often the two halves of one 128-bit hash, so one hash computation per event serves all d rows. Update increments d counters; a query reads d counters and returns the minimum. A conservative-update variant increments only counters equal to the current minimum, which reduces overestimation (Count-min sketch). The cost is that the update depends on the other counters, so the sketch is no longer a simple sum of per-event contributions and subtracting an expired bucket element-wise is unsafe. Use it only for windows that are rebuilt rather than slid. - Heap. A binary min-heap of K entries plus a hash map from item to heap index, so updating an existing candidate is O(log K) instead of a scan.
- Window state. Minute buckets are keyed state with timers at each minute boundary. When the timer fires, the shard folds the bucket into the running hour, subtracts the expiring bucket and emits the partial list.
Layer 3The machine
- Sketch memory access. A 5.4 MB sketch does not fit in a typical L2 cache but may fit in a shared L3. Each update touches d = 5 random counters; if they miss to DRAM, that is about 5 × 80 ns = 400 ns per event, so one core does roughly 2 to 3M updates/s. Laying the d counters of an item in one cache line (a "blocked" sketch) cuts that to one miss, at a small cost in accuracy.
- Heap. K = 1,000 entries of 16 B is 16 KB, which sits in L1 and L2. Heap operations cost tens of nanoseconds.
- Hash map for exact counts. A Go or Java map with 1.5M entries uses roughly 50 to 100 B per entry with overhead, about 100 MB, and rebuilding it each hour causes a GC spike. Open-addressing tables with primitive keys and values (for example 16 B per slot at 50% load) use a third of that.
- Network. Partial lists are 128 shards × 16 KB per window and scope each minute, negligible next to the event stream.
Why it's built this way
- Counting needs all of an item's events in one place, so the stream is partitioned by item. That makes merging exact and cheap.
- Partitioning by item concentrates viral items, so counts are combined before the partition step.
- Scopes multiply state faster than traffic grows, so per-scope windows use fixed-size sketches whose error is bounded and stated.
- Sketch error and streaming lateness are unacceptable for payouts, so a batch job over the durable log produces exact daily numbers.
- Readers vastly outnumber window changes, so results are computed once per minute and served as static snapshots.
Failure modes
- 10× load: ingest combiners cut record volume roughly in proportion to item concentration, so Kafka sees far less than 10×. Counter shards lag, and snapshots go stale rather than wrong; the API returns
as_ofso clients can show it. Add consumers up to the partition count, which is why it is over-provisioned. - Hot key beyond the combiner: if one item still saturates its partition, split it into
item#0..nsub-keys at ingest, and have the merger sum sub-keys before ranking. This is the only case where the merger needs to aggregate rather than concatenate. - Region outage: each region counts its own traffic and publishes regional partials. A global merger sums per-item counts across regions. Losing a region's partials makes global lists undercount that region; mark them
partial: trueinstead of silently publishing. - Counter shard crash: it restores from the last checkpoint and replays Kafka from the checkpointed offsets, so counts are neither lost nor doubled. The minute it was in may publish a few seconds late.
- Bad merger output: snapshots are immutable, so rollback moves the
latestalias back.
In the wild
- Report URI, 2021: sizing a count-min sketch for hundreds of billions of reports. With about 669 billion reports processed, the team worked out that ε = 0.001 at 99.9% confidence needs about 20,000 counters (roughly 160 KB with 64-bit counters), while tighter bounds grow to 604 million counters (4.8 GB, or 2.4 GB with 32-bit counters). It noted that its existing Redis servers had room for a sketch of that size, and pointed to a Top-K structure as the way to list the most frequent reports (Scott Helme).
- Cloudflare, 2018: precomputed rollups at 6M requests per second. Cloudflare's analytics replaced a Citus (PostgreSQL) pipeline that rolled minute aggregates up to hours, days and months with raw rows in ClickHouse plus SummingMergeTree materialized views, so the API reads from 8 pre-aggregated views instead of scanning raw requests. Over 60 billion rows were migrated (Cloudflare).
- Salesforce, 2026: heavy hitters as an operational signal. Moving Marketing Cloud caching (about 1.5M cache events per second) from Memcached to Redis Cluster exposed keys hot enough to pin a shard's single command thread. Hot keys were moved to local in-memory caches backed by Redis, and the plan for detecting new ones is a count-min sketch in the data access layer with per-key throttling (Salesforce Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Exact hash map per shard | Exact, simple | Memory grows with distinct items and scopes | Few scopes, tens of millions of items |
| Count-min sketch + heap | Fixed memory, mergeable | Overestimates by up to ε·N | Many scopes, approximate lists acceptable |
| Space-Saving | Tight bound, no hashing, mergeable | Cannot subtract an expired bucket | Tumbling windows and rollups |
| Partition by item | Exact merge by concatenation | Hot items on one partition | Default |
| Partition randomly | Even load | Merge needs full counts or is approximate | Only with a re-key step after |
| Batch for long windows | Exact, cheap per event | Hours of delay | Daily and all-time charts |
Leadership lens
- Build first: the exact global minute and hour lists with item partitioning and an ingest combiner. Add sketches only when the number of scopes makes exact maps too large, and measure that first.
- Ownership: a stream platform team owns Kafka and the counter framework; the product team owns the scope definitions and K. Adding a scope costs memory on every shard, so scope additions go through review.
- Cost drivers: counter memory (scopes × distinct items) and the batch job's scan of the day. The query tier is cheap.
- Rollout: run the new pipeline beside the old one and compare lists per window with a rank-overlap metric before switching any reader.
- Design-review questions: Which lists are allowed to be approximate, and who signed off on ε? How is the partition count chosen, and what happens if it has to change?
Check yourself
Why can't the hourly top K be computed by merging the 60 per-minute top K lists?
Show answer
An item that ranks 1,001st in every minute could be in the hourly top 1,000 yet appear in none of the minute lists. So each shard keeps full minute counts for the last 60 minutes and slides
hour_runningby adding the newest minute and subtracting the expiring one.Why does the design partition the event stream by item ID, and what does that cost when a video goes viral?
Show answer
With all of an item's events on one shard, no item appears in two partial lists, so the global top K is just the top K of the concatenation. The cost is that a viral item concentrates on one partition, which the one-second ingest combiner fixes by capping it at about one record per ingest node per second.
Exact counting for the global scope fits in memory. Why do per-scope windows switch to a count-min sketch, and how big is one sketch?
Show answer
Exact global hourly state is about 2.8 GB, or 90 MB per shard across 32 shards, but 200 countries x 30 categories pushes exact per-scope counts to tens of GB per hour. With ε = 1e-5 and δ = 0.01 a sketch is about 272k x 5 counters, roughly 5.4 MB, with an error bound of 15k views per hour.
A counter shard crashes mid-minute. Why are counts neither lost nor doubled?
Show answer
Counter state and consumed Kafka offsets are checkpointed together, so the shard restores from the last checkpoint and replays from the checkpointed offsets. The only visible effect is that the minute it was processing may publish a few seconds late.
Sources and further study
Primary
- An Improved Data Stream Summary: The Count-Min Sketch and its Applications (Cormode and Muthukrishnan): the update and query rules and the w = ⌈e/ε⌉, d = ⌈ln(1/δ)⌉ sizing.
- Mergeable Summaries (Agarwal et al., PODS 2012): proves Misra-Gries and Space-Saving merge without losing their error bound.
- HeavyKeeper (Gong et al., USENIX ATC 2018): count-with-exponential-decay top-k, the algorithm behind Redis Top-K.
- Less Hashing, Same Performance (Kirsch and Mitzenmacher): why two hash functions are enough for d rows.
Systems docs
- Apache Kafka: producer configs: the default key-hash partitioner and sticky partitioning for unkeyed records.
- Apache Kafka: basic operations: what adding partitions does to key placement and existing data.
- Distributed counters (Google Cloud Firestore docs): sharded counters, where write throughput and read cost both grow with the shard count.
- Debezium MySQL connector: turning binlog row changes into an event stream, the source of the like deltas.
Books
- System Design Interview, Volume 1, Alex Xu, chapter 13 "Design A Search Autocomplete System": top-k by frequency with offline aggregation and a precomputed serving structure.
- Designing Data-Intensive Applications, 2nd edition, Kleppmann and Riccomini, chapter 12 "Stream Processing": windows, stream state and fault tolerance for the counter shards.
Other curricula
- system-design-primer: Design Amazon's sales rank by category: frames top-k as an hourly batch MapReduce over logs into a SQL table, the slow exact path of this design.
- Azure Architecture Center: Materialized View pattern: frames the top-k snapshot as a disposable projection, built and validated before it is swapped in.
Watch
- System Design Interview: Top K Problem (Heavy Hitters) (System Design Interview): a thorough walkthrough of the fast approximate path and slow exact path, including count-min sketch.
Free resources
- Efficient Computation of Frequent and Top-k Elements in Data Streams (Metwally, Agrawal, El Abbadi): the Space-Saving algorithm and its error guarantees.
- Count-min sketch (Wikipedia): the update and query rules, the error bound and the conservative-update variant.
- Top-K (Redis docs): a production heavy-hitters structure and the parameters it exposes.
- Count-min sketch (Redis docs): how width and depth map to error and probability in a real implementation.
- Frequent items sketches (Apache DataSketches): a mergeable frequent-items sketch used in large analytics systems, with its accuracy trade-offs.