System Design Case Studies 2

Design Trending Topics and Top-K Requests

Streaming counts, sliding windows, a count-min sketch with a heap, and merging top-K across partitions.

Lesson 0522 min read#top-k#count-min-sketch#streaming

On this page

  1. Words used in this lesson
  2. Requirements
  3. Back-of-envelope numbers
  4. API
  5. Data model
  6. High-level architecture
  7. Deep dives
  8. Failure modes and trade-offs
  9. How to say it in an interview (60 seconds)
  10. Run it yourself
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: "trending topics" means finding the K most frequent items (hashtags, search queries, videos) in a stream of events, for a recent time window, fast. Counting everything exactly works until the number of distinct items is huge. The tools to know are the count-min sketch (a small table that gives counts that are never too low and sometimes a little too high), a min-heap of size K, time buckets for sliding windows, decay so old bursts fade, and a plan for hot keys and for merging partial results from many machines.

This is lesson 5 of System Design Case Studies, Part 2. The key-value store in Lesson 1 gave us partitioning by key hash. Here we count events in a stream and combine counts from many partitions. A later case, Lesson 9 (sales ranking by category), asks a similar "rank things" question over a smaller, more exact data set, and Lesson 10 covers the pipeline that feeds streams like this one.

What I ran / checked. I wrote three TypeScript files and ran them on Node 22.22: sketch.ts (a count-min sketch, a min-heap top-K with a position map, a Zipf sampler), topk.ts (error measurements, partitioning, merging, hot keys) and window.ts (60 one-minute sketches for a sliding window, and exponential decay). All outputs below are real. The event streams are made up (Zipf-shaped, fixed random seed), so the numbers show behaviour and are not measurements of any real service. I did not re-read the original count-min sketch paper (Cormode and Muthukrishnan, 2005) or the Space-Saving paper; the error bound I quote (estimate <= true + (e/w) x N, with probability 1 - e^-d) is from memory, and I checked it by measuring the maximum error in my runs. The system-design-primer's list of extra interview questions links to an article on trending topics in Storm and to papers on top-k requests in a time interval; I saw only the link titles and did not open them.

Words used in this lesson

  • Stream is data that keeps arriving (one event per search, per view, per like). You cannot stop it and read it twice.
  • Top-K (also heavy hitters) are the K items with the largest counts.
  • Window is the time range you count in, such as "the last hour". A tumbling window is a fixed block (10:00 to 11:00, then 11:00 to 12:00). A sliding window moves with time ("the last 60 minutes until now").
  • Exact counting keeps one counter per distinct item. Approximate counting uses less memory and accepts a small, known error.
  • Count-min sketch is a table of counters with d rows and w columns. Each row has its own hash function. To add an item, add 1 to one cell in every row. To estimate an item, read its cell in every row and take the minimum.
  • Collision is when two different items hash to the same cell. It only ever makes a count too high, never too low. That is why taking the minimum works.
  • Min-heap is a tree where the smallest value is at the top. A heap of size K whose top is the K-th largest count lets you ask "does this item beat the current K-th?" in one step.
  • Shard (partition) is one machine's share of the data. Here we split by a hash of the key.
  • Hot key is one key that gets so many events that its shard is overloaded.
  • Decay means older events count less. Half-life is the time after which an event's weight is halved.
  • Zipf distribution is a popularity pattern where item number k gets a share proportional to 1/k^s. Search queries and hashtags often look roughly like this (an assumption; check on real data).

1. Requirements

Functional

  • Ingest a stream of events (a search, a hashtag use, a video view).
  • Return the top K items (say K = 10 to 100) for a window (last 5 minutes, last hour, last day) and for a scope (global, country, language).
  • Show "trending" items: those that rise fast, not only those that are always big.
  • Optional: filter out spam and banned terms, and group variants (#Cup, #cup).

Non-functional

  • Fresh: a new burst should show in the list within about a minute (assume).
  • Fast reads: the top-K list is read much more often than it changes. Serve from a cache in single-digit milliseconds.
  • Scalable writes: peak is hundreds of thousands of events per second.
  • Bounded memory even if the number of distinct items is in the hundreds of millions.
  • Approximate is acceptable for the long tail, but the top of the list should be right, and the error should be known.
  • Fault tolerant: losing one machine must not lose the ranking.

Out of scope here: natural-language grouping of similar topics, and moderation policy.

2. Back-of-envelope numbers

Every number is an assumption.

TEXT
Events per day            5 billion              (hashtag uses + queries)Average rate              5e9 / 86,400 = ~58,000 events/sPeak rate                 3 x average = ~175,000 events/sEvent size on the wire    100 bytes  -> 175,000 x 100 B = 17.5 MB/s at peak  (tiny)Distinct items per hour   50 million (assume most appear once or twice)Exact counting, 1 hour:   50M items x (~10 B key + 8 B counter) = 0.9 GB   (real hash tables add overhead,                                                                           so plan for 2 to 3x that)Many scopes: 20 regions x 5 windows x several languages, each needing its own table, so exact tables multiplyCount-min sketch, one bucket:  w = 2,000 columns x d = 5 rows x 4 B = 40 KB60 one-minute sketches (1-hour sliding window) = 60 x 40 KB = 2.4 MB per scope24 h of 1-minute buckets = 1,440 x 40 KB = 56 MB per scope                 (fits anywhere)

What this arithmetic shows:

  • The network and CPU load is small: 175,000 events/s is easy to split over a few dozen machines.
  • The hard part is memory over many scopes and windows. One exact table for one hour is fine (under 3 GB). But 20 regions x 5 windows x many languages, and long histories, grow fast. A sketch makes each scope cost kilobytes.
  • Also say plainly: if the whole stream fits on one box with exact counters, use exact counters. They are simpler, and there is no error to explain. The sketch is for when it does not fit.

3. API

HTTP
# Ingest (internal; usually a message queue, not HTTP)POST /v1/events[ { "item": "#worldcup", "ts": 1760000000, "region": "IN", "lang": "hi" }, ... ]    # a batch→ 202 Accepted# ReadGET /v1/trending?region=IN&window=1h&k=10→ 200 OK{ "window": "1h", "asOf": "2026-10-11T10:04:00Z",  "items": [ { "item": "#worldcup", "count": 182340, "approx": true, "error": "+/- 120",               "trend": 3.4 }, ... ] }# AdminPOST /v1/blocklist   { "item": "#spamtag" }

Notes:

  • Writes should be batched and go through a queue. The ingest endpoint only answers "accepted".
  • The response says approx: true and gives an error bound. An honest API tells the reader that counts are estimates.
  • trend is a ratio (current rate divided by the usual rate; see section 6.6).

4. Data model

TEXT
Queue (Kafka-style):   topic "events", partitioned by hash(item)      -> one item always goes to one partitionWorker state (memory): per partition, per minute:   CountMin(w, d) + small TopK heap of candidate itemsSnapshots (object store, every minute):  sketch tables + candidate lists, so a restarted worker reloads themServing store (Redis sorted set):  key "trend:{region}:{window}"  member=item  score=count                                   refreshed every 10-60 s by a merger job; clients read ZREVRANGE 0..K-1

A Redis sorted set (ZSET) keeps members ordered by a numeric score. Reading the top 10 is one call. The read path never touches the sketches.

5. High-level architecture

TEXT
 Clients ---> API/ingest ---> Queue: topic "events", key = hash(item), P partitions                                         |                    +--------------------+--------------------+                    v                    v                    v              Worker 1             Worker 2    ...       Worker P         (each owns some partitions)        per-minute CountMin    per-minute CountMin    per-minute CountMin        + candidate heap       + candidate heap       + candidate heap                    |                    |                    |                    +----- every 10-60 s: local top-K candidates (and counts for the window) ----+                                         v                                   Merger job              (sum bucket counts over the window, apply decay, take the global top-K)                                         v                              Redis ZSET  trend:{region}:{window}                                         v                      Trending API (cache, CDN for 10-30 s)  <--- readers

Because the queue is partitioned by the item, all events for one item reach one worker. So each worker's counts for its items are complete, and the global top-K is simply the top-K of the workers' top-K lists (proof in 6.4). The sketch is only needed because each worker still sees millions of distinct items.

6. Deep dives

6.1 Exact versus approximate

Exact hash mapCount-min sketch + heap
Memorygrows with distinct items (50M items ~ 1 GB or more)fixed: w x d x 4 bytes, no matter how many items
Errornonecounts can be too high, never too low
Can list its items?yesno (you must keep a separate candidate list, the heap)
Delete / expireeasyonly by subtracting or by rotating buckets
Merge across machinesadd mapsadd tables cell by cell (exact, section 6.4)
Complexitytrivialsmall, but you must explain the error

The honest decision rule: use an exact map until it stops fitting. Then use a sketch for counting and a small heap for listing.

6.2 The count-min sketch and the min-heap

Here are the two data structures I ran (sketch.ts, shortened where marked):

JavaScript
export class CountMin {  w: number; d: number; table: Uint32Array; total = 0;  constructor(w: number, d: number) { this.w = w; this.d = d; this.table = new Uint32Array(w * d); }  add(key: string, n = 1) {                       // +n in one cell of every row    for (let r = 0; r < this.d; r++) this.table[r * this.w + (hash32(key, r * 0x9e3779b1 + 1) % this.w)] += n;    this.total += n;  }  estimate(key: string): number {                 // the minimum over the rows    let m = 0xffffffff;    for (let r = 0; r < this.d; r++) { const v = this.table[r * this.w + (hash32(key, r * 0x9e3779b1 + 1) % this.w)]; if (v < m) m = v; }    return m;  }  merge(o: CountMin) {                            // same w, d and hashes: add cell by cell    for (let i = 0; i < this.table.length; i++) this.table[i] += o.table[i];    this.total += o.total;  }}// TopK (not shown in full): a min-heap of {key, count} with a Map<key, position>,// offer(key, count): if the key is in the heap, update it; else if the heap has room, add it;//                    else if count > heap top (the smallest of the K), replace the top.

For every event the worker does: cm.add(key); heap.offer(key, cm.estimate(key)). Cost per event is d hash computations plus O(log K) for the heap. With d = 5 that is tiny.

Error. Let N be the total number of events in the sketch. The rule I remember: if w = e/ε columns and d = ln(1/δ) rows, then with probability at least 1 - δ the estimate is at most true + ε x N. I measured it on 1 million events (88,109 distinct items), d = 5:

TEXT
   width | memory  | bound e/w*N | max over-count (all keys) | mean over-count, top-10 | mean over-count, 1000 rare keys | top-10 correct     500 |   10 KB |        5437 |                      2718 |                   626.5 |                           595.4 | 10/10    2000 |   39 KB |        1359 |                       728 |                   118.2 |                           102.3 | 10/10    8000 |  156 KB |         340 |                       153 |                    18.1 |                            15.7 | 10/10   32000 |  625 KB |          85 |                        22 |                     2.5 |                             1.5 | 10/10

Read it carefully:

  • The worst error stayed under the bound in all four runs, and went down as the table grew (quadruple the width, about 4 times smaller error).
  • The error is about the same for every key (118 for the top-10, 102 for rare keys at width 2,000). It depends on how crowded the table is (N / w), not on how big the key is.
  • So the relative error is tiny for heavy keys (118 on 130,823 is 0.09%) and huge for rare ones (a rare key with a true count of 1 or 2 reads as about 100). A sketch is good at finding the big ones and bad at counting the small ones. That is exactly what top-K needs, and it is why you must not use it to show a count for a rare item.

The top-10 from the 39 KB sketch with the heap, against the exact counts:

TEXT
   rank key       estimate exact  error    1   t0         131017 130823   194    2   t1          61054  60959    95    3   t2          39056  38961    95    4   t3          28701  28615    86    5   t4          22353  22240   113    ...   10   t9          10451  10340   111   exact map needs at least 88109 x (about 10 B key + 8 B count) = 1.6 MB; the sketch is 39 KB

The order is right, and the counts are about 0.1% high. In this test an exact map would be 1.6 MB, which is small. A sketch only wins when the distinct count is much larger (50 million items in section 2 means about 0.9 GB or more) or when you keep thousands of scopes and buckets.

A common improvement (not tested here). Conservative update only raises the cells that are currently the smallest for the item, which lowers over-counting. Space-Saving is a different algorithm that keeps exactly K counters and needs no hash table at all; I did not re-read its paper.

Over-counting means items near the K-th place can swap order, or an item just below the K-th can slip in. Items far above the K-th place are safe, because an estimate is never below the true count. Always check the final list against exact counts in a test, and show only as many places as the error allows you to separate.

6.3 Sliding windows: ring of buckets

A single sketch cannot forget. So split time into buckets (one per minute), keep one sketch per bucket, and the window "last 60 minutes" is the sum of the last 60 sketches. When a minute ends, the oldest bucket is reused (cleared). I ran a 180-minute scenario: topic Y is huge in minutes 0 to 59 and then stops, X is busy in minutes 100 to 129, Z starts at minute 165, and 1,000 background events per minute come from 5,000 items.

TEXT
at minute 179all-time counts       : Y=180000  X=45000  bg0=19870last 60 min (sketches): X=15000  Z=12000  bg0=6678last 60 min (exact)    : X=15000  Z=12000  bg0=6678

The all-time list is dominated by a topic that died two hours ago. The windowed list is right, and the 60 sketches (one per minute, 1,000 x 4 x 4 B = 15.6 KB each) match the exact answer. Memory is 938 KB for this scope.

How a query works: a sketch cannot list its keys, so each bucket also keeps a candidate list (a heap of its top 20). The query takes the union of the candidates, and for each candidate adds its estimate over the 60 buckets. A key that is not a candidate in any bucket but is moderately big in all of them can be missed (the same problem as in 6.4).

Trade-offs of bucket size: smaller buckets (10 s) give a smoother window edge and fresher results but more buckets to sum. A one-hour window with one-minute buckets has an edge error of up to one minute. The reads are cheaper if you pre-add: keep a running sum, add the new bucket, subtract the expiring one (this works because sketches are linear).

6.4 Partitioning and merging the top-K

How should the stream be split over machines? Two choices, and the choice changes the merging rule.

(a) Partition by the item (hash of the key, as in Lesson 1). All events for an item go to one worker, so the worker's count for it is complete. Then the global top-K is contained in the union of the local top-K lists: if an item is in the global top K, it cannot be outranked by K items on its own shard (they would also outrank it globally), so it is in its shard's local top K. Merge = take the top K of the union. It is exact (given exact local counts). I tested this with 4 shards:

TEXT
   (a) by key hash, local top-10 merged:   10/10 of the true top-10, counts exact

(b) Spread events round-robin (or by user, or by time). Each worker sees only part of each item's count. The local top-K lists can now disagree. Two facts:

  • Sketches merge exactly. Four sketches built on four slices of the stream, added cell by cell, are identical to one sketch built on the whole stream. I checked every cell: same? true. This is because the table is just a sum.
  • Local top-K lists do not merge exactly. An item that is 11th on every shard can be 1st globally. I tested this with flat popularity (Zipf exponent 0.15, 8 shards, round-robin):
TEXT
3b. Flat popularity (zipf s=0.15, 5,000 keys, 200,000 events), 8 shards, events spread round-robin   each shard sends its local top-10  -> 6/10 of true top-10 found, reported counts too low by 55 on average   each shard sends its local top-20  -> 8/10 of true top-10 found, reported counts too low by 48 on average   each shard sends its local top-50  -> 8/10 of true top-10 found, reported counts too low by 30 on average   each shard sends its local top-200 -> 10/10 of true top-10 found, reported counts too low by 9 on average

With the skewed Zipf stream in the earlier test (exponent 1.1) the round-robin merge found 10 of 10 even with local top-10 lists, because the big items are far ahead of the rest. With flat popularity it found only 6. The lesson: local top-K merging is safe only if the data is skewed or the partition is by key. If you must partition another way, either send bigger local lists (K x 10 or more), or send the sketches and build the candidate list from the union of all local candidates, then query every candidate in the merged sketch (a second pass).

6.5 Hot keys

Partitioning by item has one weak point: a single item that is a large share of all traffic lands on one worker. I simulated a key that gets 30% of the events, over 8 shards:

TEXT
   hot key split in 1: busiest shard 3.69x the mean, quietest 0.33x   hot key split in 8: busiest shard 1.43x the mean, quietest 0.57x

The fix is to split the hot key into sub-keys (HOT#0 to HOT#7) by a random suffix on the way in. Each sub-key goes to a different worker. The merger adds the counts of all sub-keys back together (strip the suffix). The busiest shard went from 3.69x to 1.43x the average load.

How do you know which key is hot? Two ways: a first-stage local pre-aggregation at the ingest layer (each ingest server counts for 1 second, then sends (item, count) pairs, which also reduces queue traffic a lot), or a small detector (a heap on a sketch) that flags items over some share of traffic and switches on key-splitting for them. Pre-aggregation is the first thing I would add.

Raw counts say what is big (the word "the", a news site that is always popular). Trending means what is rising. Two common scores (my wording of common practice, not a specific product's formula):

  • Ratio: score = (count in the last 10 minutes) / (expected count for 10 minutes), where expected comes from the same hour in past weeks or a long-window average. Add a small constant to the denominator so a brand-new term does not divide by zero. Also require a minimum count so a term that went from 1 to 5 is not "5x trending".
  • Decay: keep one score per item, score = score x 0.5^(elapsed / half-life) + 1 for each event. A burst scores high and then fades by itself, and you need only one number and one timestamp per item, not buckets.

I ran the decay on the same 180-minute scenario with a 15-minute half-life:

TEXT
decayed score (half-life 15 min): Z=8858  X=2472  bg0=2445

The windowed count put X (15,000 in the last hour, but all of it more than 50 minutes ago) first and Z (12,000, rising now) second. With decay, Z is first by a wide margin (8,858 against 2,472), because its events are recent. Which is "right" depends on the product. Decay is smooth and cheap. A window is easy to explain.

Decay inside a sketch: one way is to multiply the whole table by a factor every minute (cost: touch every cell). A cheaper trick (from memory, "forward decay"): give each event a weight that grows with time (e^(lambda x t)), add that to the sketch, and divide by the current factor at read time. Weights are stored in floating point, so you must re-scale now and then. I did not run this variant.

6.7 Spam, bots and one noisy user

A top-K system is a target: a botnet can push a hashtag into the list. Basic defences: count unique users per item (each user counts once per window) rather than raw events, which needs a distinct-count sketch (HyperLogLog is the usual tool; not tested here); rate-limit per user and per IP; a blocklist applied before counting; and a human review queue for items that enter the list from nothing very fast.

6.8 Serving reads

The merger writes the final list into a Redis sorted set (or just a JSON blob) per scope and window, every 10 to 60 seconds. API servers cache it for a few seconds, and a CDN (Lesson 4) can cache it for 10 to 30 seconds. Readers see data that is at most a minute old, and read cost is independent of event volume. Keep the previous list: if the merger fails, serve the old one with a stale marker rather than an empty page.

7. Failure modes and trade-offs

ProblemEffectHandling
Worker crashesIts in-memory sketches are lostReload the last minute snapshot and replay the queue from the saved offset (the queue keeps the events)
Duplicate events (queue retries)Counts too highDedupe with an event id in a short window, or accept small error and say so
Late events (arrive after their minute ended)Wrong bucketPut them in the current bucket (simple), or allow a lateness window of a few minutes before closing a bucket
Hot keyOne shard overloadedPre-aggregate, split the key into sub-keys, merge on read
Sketch too smallMany items look bigWatch N / w; the error is about N x 2.7 / w; resize at the next bucket
Bot attackFake topic in the listUnique-user counts, rate limits, blocklist, review
Merger failsList goes staleServe the previous list, alert if older than a few minutes
Clock skew between ingest serversEvents in wrong minuteUse the event time stamped by the server, or one time source; allow lateness
Shard count changesItems move to other workersUse consistent hashing; rebuild from snapshots; keep the old buckets read-only until they expire

Trade-offs to say aloud

  • Exact vs approximate: memory and merge cost against explaining an error. Start exact.
  • Window vs decay: easy to explain against smooth and cheap.
  • Freshness vs cost: a 10-second merge is fresher and costs 6 times more merging than a 60-second one.
  • Partition by item vs spread: by item gives exact merging but hot keys; spreading fixes hot keys and breaks local top-K merging.
  • Bucket size: small buckets are fresh and costly; big buckets are cheap and blurry.

8. How to say it in an interview (60 seconds)

"Events go to a partitioned queue keyed by the item, so one worker sees all events for an item. Each worker keeps one count-min sketch per minute plus a small heap of candidate items. A sketch is a d x w table: add one cell per row, estimate with the minimum over rows, so it never undercounts. With 40 KB and one million events, my test showed errors of about 0.1% on the top items and the right top 10, though rare items read about 100 too high, so a sketch finds big items and does not count small ones. The window is the sum of the last 60 minute-buckets; I tested it against exact counts and it matched. Because we partition by item, the global top-K is the top-K of the local top-K lists, exact. If we partition differently, sketches still add cell by cell, but local lists can miss items on flat data (6 of 10 in my test), so send bigger lists. For a hot key I split it into sub-keys: busiest shard from 3.7x to 1.4x the mean. For trending I rank by rise over the usual rate or by an exponential decay score, and a merger writes the result into a Redis sorted set that the API caches. If the data fits in exact counters, I would use those."

Run it yourself

Files: sketch.ts (classes), topk.ts (about 5 seconds) and window.ts. Run node topk.ts and node window.ts in one folder. They import with ./sketch.ts and use only Node's standard library. Constructor parameter properties are avoided because Node's type stripping does not support them.

Fixed random seeds and a Zipf stream make my results repeatable but not general. A real stream has bursts, daily cycles, bots and duplicates. Re-measure on your data, and measure error against an exact count taken from a sample.

Interview questions

Design "top 10 trending hashtags in the last hour". Where do you start? (Must know)

I ask: how many events per second, how many distinct hashtags, which scopes (global, per country), how fresh. Then I do the arithmetic. If the distinct count for an hour fits in memory exact counters are enough, and I say so first. If not, I use a count-min sketch per minute and a heap of candidates. The queue is partitioned by hashtag, a merger sums the last 60 buckets, and Redis serves the list.

How does a count-min sketch work, and why does it only over-count? (Must know)

It is a d x w table of counters with one hash function per row. To add an item I add 1 to one cell in every row. To estimate, I read the item's cell in every row and take the minimum. Another item can hash to the same cell and add to it, but nothing ever subtracts, so every cell is at least the true count. The minimum over several rows is the least damaged one. In my test, 39 KB gave an error of about 100 to 200 on counts of 10,000 to 130,000.

What are the error bounds, and how do you choose w and d? (Senior signal)

From memory of the paper: with w = e / epsilon and d = ln(1 / delta), the estimate is at most true + epsilon x N with probability at least 1 - delta, where N is the total count. In my run with N = 1 million and w = 2000 the bound was 1,359 and the worst error was 728. I choose epsilon as a small fraction of the K-th item's count, so the error cannot reorder the top list, then d of 4 to 5 for delta around 1 to 2%.

Why do you need a heap in addition to the sketch? (Must know)

A sketch can answer "how many for this key?" but cannot list its keys. So each time I see an event I estimate its count and offer it to a min-heap of size K. The heap holds the current best K. Offering costs O(log K). The heap top is the K-th largest count, so most events are rejected in one comparison.

A sketch gives a wrong count for a rare item. Is that a bug? (Trap)

No. The error is about the same for all keys (in my test around 100 at width 2,000), so for an item seen once or twice the relative error is huge. The sketch is meant for finding heavy items. If the product shows exact counts for small items, I would use a separate exact store for them, or show only rounded counts such as "1.2K".

How do you implement the sliding window? (Must know)

I split time into buckets, for example one per minute, with one sketch for each. The window is the sum of the last N buckets, and the oldest bucket is reused. Because sketches are linear, I can keep a running sum, add the new bucket and subtract the expired one. To list items I keep a candidate heap in each bucket and query the union of the candidates. In my 180-minute test, the window matched the exact answer and the all-time list was wrong.

How do you merge top-K results from many machines? (Senior signal)

It depends on the partition. By item: all events for an item are on one machine, so the top K of the union of local top-K lists is exact. Spread across machines: sketches still merge exactly by adding cells (I checked that every cell matched), but local top-K lists do not. On flat data in my test, local top-10 lists found only 6 of the true 10; top-200 lists found all 10. So I send bigger lists, or do a second pass that queries the merged sketch for every candidate.

What do you do about a hot key? (Senior signal)

I first pre-aggregate at the ingest layer so the queue sees (item, count) pairs. For a key that is still too big I split it with a random suffix into several sub-keys on different workers, and add them together at read time. In my test the busiest shard went from 3.69x to 1.43x the mean with 8 sub-keys. I detect hot keys with a small sketch or a counter per ingest server.

What is the difference between "top" and "trending"? (Must know)

Top is by size, trending is by change. A permanently popular term is top but not trending. I use a ratio of recent count to the usual count (with a minimum count and a smoothing constant), or an exponential decay score. In my decay test with a 15-minute half-life, the topic active right now scored 8,858 and a bigger burst from an hour ago scored 2,472.

Why not just use a database with GROUP BY and ORDER BY count DESC LIMIT 10? (Must know)

At low volume I would. It is simple and exact, and the first answer should say so. At 175,000 events per second the table is a write hot spot, the sort over millions of groups is costly, and a time window needs a time index and deletes. A stream processor with in-memory counters, or sketches, keeps the work in memory and the database sees only the result.

How do you handle late and duplicate events? (Senior signal)

Late events: use event time and a small lateness allowance; an event older than the allowance goes into the current bucket or is dropped and counted in a metric. Duplicates: queues usually give at-least-once delivery, so I add an event id and dedupe within a short window, or accept the error and state it. For top-K a small amount of duplication rarely changes the order, but a replay after a crash can double a minute, so I save the offset with the snapshot.

Worker crashes. What do you lose? (Senior signal)

Its in-memory sketches for the open buckets. Every minute I write a snapshot with the queue offset. On restart I load the snapshot and replay events from that offset; the queue keeps them. The merger keeps serving the previous list in the meantime. Closed buckets are already in the object store.

How would you make it resistant to bots? (Trap)

Raw event counts are easy to inflate, so I count distinct users per item (with HyperLogLog or a per-window set), cap each user's contribution, rate limit per IP, apply a blocklist before counting, and put items that appear from nothing in a review queue. I say I did not test these, and that the fight is ongoing.

If memory is tiny, what other algorithm would you use? (Senior signal)

Space-Saving, which keeps exactly K counters. When a new item arrives and the table is full, it replaces the smallest counter and starts from that counter's value plus one, so counts never go below the truth and the error is bounded by the smallest counter. I did not re-read the paper and I did not test it here. Compared with a sketch, it needs no hash table but is harder to merge across machines.

Key takeaways

  • Do the arithmetic first. If exact counters fit, use them. A sketch is for when distinct items or the number of scopes make exact counting too big.
  • A count-min sketch is a d x w table: add to one cell per row, read the minimum. It never undercounts. Measured: 39 KB gave about 0.1% error on the top items and the right top 10, while rare items read about 100 too high.
  • A sketch cannot list keys. Pair it with a min-heap of size K.
  • Sliding window: one sketch per time bucket, sum the last N. Sketches add cell by cell, exactly.
  • Partition by item and the global top-K is the top-K of local top-K lists. Spread events, and you need bigger lists (6 of 10 found with top-10 lists on flat data, 10 of 10 with top-200).
  • Hot key: pre-aggregate and split into sub-keys. Measured: 3.69x to 1.43x the mean load.
  • Trending means rising, not big: use ratio to baseline or exponential decay.

What's next

Lesson 6: Design a Unique ID Generator. The queue, the shards and the buckets in this lesson all need names that never repeat. That lesson shows how to make 64-bit IDs that are unique across machines and sort by time.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series