Design top-K and leaderboards
"Top 100 songs this hour", "trending hashtags", "your rank among 50 million players". All of these ask for the biggest few out of a huge, fast-changing set. With a few thousand items you sort a table; with hundreds of millions of events an hour you need sharded counting, time buckets and sometimes a sketch that trades a little accuracy for a thousand times less memory. It follows the interview framework phase by phase.
1. The prompt
Two related interview questions, often asked as one: "Design a real-time game leaderboard" (exact ranks for every player) and "Design top-K most played songs or trending items" (the top few out of billions of events, per time window).
They share the word "top" but not the solution: a leaderboard is a sorted index you can ask "where am I?", while top-K is a counting problem over a stream. Say which you're designing, or cover both in turn.
2. Frame the problem
Leaderboard: how many players?
50M in one game season.
Fits in memory in one sorted set.
Must ranks be exact?
Yes for the leaderboard (prizes); approximate is fine for trending.
Exact structures for one, sketches allowed for the other.
Top-K: how many events?
1B plays a day, peaks ~5× average.
~60,000 a second at peak: shard the counting.
Which windows?
Last minute, hour and day; globally and per region.
Time buckets, not one counter per item.
How fresh?
Leaderboard within a second; charts within a minute.
Charts can be precomputed and cached.
How big is K?
100.
The merge step stays tiny.
3. What it must do
- Leaderboard: update a player's score; show the top 100 and any player's exact rank and neighbours.
- Top-K: the top 100 items for the last minute, hour and day, globally and per region.
Not now:
- Anti-cheat (mention it).
- Recommendation (a different problem).
4. How well it must do it
| Target | Why this number |
|---|---|
| Leaderboard updates visible within a second; ranks exact speed · consistency | Players watch their rank change as they play; prizes depend on it. |
| Top-K fresh within a minute; small errors acceptable for trending freshness | Nobody notices song #73 and #74 swapped. |
| Scale: 50M players; 1B plays a day load shape | Reads of the top lists are far more frequent than any single item's count. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Leaderboard size | 50M players × ~60–100 bytes in a sorted set | ~3–5 GB → so one Redis node, replicated for reads and failover |
| Score updates | Say 50,000 a second at peak | Each O(log N) → so fine for one node; split by game or season beyond that |
| Play events | 1B a day, peak ~60,000 a second | Too many for one counter store → so shard counting by item id |
| Distinct items | Say 100M songs, videos or hashtags | Exact counters per window are large → so a Count-Min Sketch of a few MB where estimates are fine |
| Hour window | 60 one-minute buckets | → so slide by adding one bucket and dropping one |
6. The nouns
- Score: a player's current total.
- Play event: one play of one item, with a time.
- Bucket: counts per item for one minute.
- Chart: a precomputed top-K list for a window and region.
scores (game_id, player_id) → score, reached_at (database: the source of truth)
lb:{game} Redis sorted set: member player_id, score (rebuilt from scores)
play item_id, user_id, region, played_at (event stream, partitioned by item_id)
buckets (shard, minute) → item_id → count
charts (window, region) → [(item_id, count) × 100] (cache)
7. The contract
POST /v1/games/{g}/scores {delta} → ZINCRBY lb:{g} delta player (player from the session)
GET /v1/games/{g}/top?n=100 → ZREVRANGE lb:{g} 0 99 WITHSCORES
GET /v1/games/{g}/rank/{player} → ZREVRANK lb:{g} player (+ ZREVRANGE around it for neighbours)
GET /v1/charts/top?window=1h®ion=IN&n=100 → precomputed list from cache
Data flow: leaderboard: (1) score update → (2) database + sorted set → (3) reads go to replicas. Top-K: (1) play events into a stream partitioned by item → (2) counter shards keep minute buckets → (3) each shard reports its local top K → (4) a merger combines them every few seconds → (5) charts cached for readers.
8. First working version
One database table of scores and one table of play events, queried directly.
8.1 "What's my rank?"
8.2 Top songs this hour
SELECT item_id, COUNT(*) FROM plays WHERE played_at > now() - 1h GROUP BY item_id ORDER BY 2 DESC LIMIT 100, on every request.
Parked for later (section 9 fixes each)
- A rank means counting everyone above you: millions of rows per request.
- The top-songs query scans an hour of events (~40M rows) every time.
- 60,000 events a second into one table.
- Counters for 100M items in every window.
- One viral item hammering one place.
9. Make it hold up
9.1 Exact leaderboards with sorted sets speed · exact
| Approach | Why | |
|---|---|---|
| ✗ Bad | SELECT … ORDER BY score DESC and COUNT(*) WHERE score > mine on every request | Rank needs counting everyone above you: a scan over millions of rows per request. |
| ~ Good | An index on score in the database, with the top 100 cached | Top lists are fast, but "my rank" is still a large range count, and every update rewrites the index. |
| ✓ Great | A Redis sorted set: a skip list plus a hash, so ZADD/ZINCRBY, ZREVRANK and range reads are O(log N) | Rank and neighbours for any of 50M players in microseconds. Persist scores in a database too; the sorted set can be rebuilt from it. |
9.2 Beyond one node scale
Most "leaderboards" are naturally many small ones: split by game, region or season, and each is its own sorted set. For one giant global board, shard by score range and keep a count per shard, so a rank is "players in higher shards + rank inside my shard". Ties are broken consistently (earliest to reach the score, folded into the stored score) so ranks don't flicker.
9.3 Top-K over a stream fresh · scale
- Shard by item id: every play of song 4200 goes to the same counter shard (partition the event stream by item id). Each shard keeps exact counts for its items and its local top K.
- Merge: because each item lives on exactly one shard, the global top K is the top K of all the shards' local top-K lists combined. That is exact, and the merge is tiny.
- Serve from cache: recompute the lists every few seconds and cache them; readers never touch the counters.
9.4 Sliding windows with buckets fresh
Keep counts in one-minute buckets. "Last hour" = sum of the last 60 buckets; slide by adding the newest minute and subtracting the one that fell out. Hour and day buckets roll up from minutes, so "last day" sums 24 hour buckets instead of 1,440 minute ones. Old buckets are dropped; the raw stream is kept elsewhere if anyone needs to recount.
9.5 When memory is the limit: Count-Min Sketch scale · cost
A Count-Min Sketch is a small grid of counters: d rows, each with its own hash function, w columns wide. To count an item, add 1 to one cell per row; to estimate it, take the minimum of its cells. Collisions only ever add, so the estimate is never too low, and a wider grid means fewer collisions. Keep a small heap of the K items with the highest estimates, and you have approximate top-K in kilobytes instead of a counter for every item. Redis Open Source includes Count-Min Sketch and Top-K data structures.
Use it where small errors are fine (trending, analytics). For anything paid out (game prizes, royalties), use exact counts, or use the sketch only to pick candidates and count those exactly. Race the two in section 13.
9.6 Hot items scale
One viral item on one shard can overload it. Pre-aggregate on the producer side (count locally for a second, send one increment of +350 instead of 350 increments), or split the key as in Design D17 and add the parts during the merge.
9.7 The whole design after the deep dives
A score update and a chart refresh through the final design. Press Play or drag the timeline.
10. Interviewer follow-ups
Show "friends only" leaderboards.
Fetch the friends' scores (ZMSCORE) and sort the few hundred in the app; no separate sorted set per user.
Weekly reset.
A new sorted set per week (lb:{game}:2026-w42); the old one is archived to the database and expires. No mass delete at midnight.
Redis node fails.
Promote a replica; for anything lost since the last replication, rebuild from the scores table (the source of truth).
Top-K per region and per genre.
Each dimension is another key in the bucket; keep the combinations people actually use, and compute rare ones on demand from the stream archive.
Heavy hitters with error bounds.
Algorithms such as Space-Saving or Misra-Gries track the top items in O(K) memory with guaranteed error bounds; Count-Min with a heap is the common practical choice.
Cheating.
Validate score deltas server-side against game events, rate-limit updates, and hold prize-tier ranks for review before payout.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Sorted set primary down | Brief stale ranks | Replication health | Replica promotion; rebuild from DB |
| Counter shard down | Charts miss some items briefly | Consumer lag | Restart from stream offset |
| Merger down | Charts stop updating | Chart age | Serve the last cached chart |
| Viral item | Lag on one shard | Per-key rate | Producer pre-aggregation; key splitting |
12. Wrap-up and the clock
Trade-offs we made:
- Exact vs approximate: exact counting needs memory per distinct item; sketches need fixed memory and give one-sided errors.
- Freshness vs cost: recomputing top-K every second is costly; every 10–60 seconds is usually enough for charts.
- Sorted set in memory, database as the truth.
- Shard by item so the merge stays exact.
What we'd watch:
- Rank read p99.
- Chart age.
- Consumer lag per shard.
- Sketch error vs an exact sample.
- Hot keys.
At 10×:
- More counter shards.
- Producer pre-aggregation by default.
- Sketches for long-tail windows.
- Score-range sharding for giant boards.
A 45-minute interview
| 0–4 | Frame the problem (which "top", exactness, windows) |
| 4–9 | Must do, how well, numbers |
| 9–12 | Nouns, contract, data flow |
| 12–18 | First working version, parked list |
| 18–40 | Deep dives: sorted sets (9.1), top-K over a stream (9.3), windows (9.4), sketches (9.5) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–11 | Must do, how well, numbers |
| 11–15 | Nouns, contract, data flow |
| 15–23 | First working version |
| 23–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: friends, resets, heavy hitters; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | A sorted set for the leaderboard; counting plays and sorting for top-K; caching the result. |
| Senior | Why ranks are O(log N) in a sorted set, sharding counts by item and merging local top-K exactly, minute buckets for sliding windows, cached results. |
| Staff+ | Count-Min Sketch and heavy-hitter algorithms with their error bounds, exact-vs-approximate per use case, giant leaderboards by score range, hot keys, regional charts and anti-abuse. |
Common mistakes:
- COUNT(*) for ranks.
- Sharding counts randomly, then merging local top-Ks (inexact).
- One counter reset hourly.
- Sketches for prize money.
- No source of truth behind the cache.
13. Try it, check yourself, read more
60,000 plays of 5,000 songs, where a few songs are hugely popular (a Zipf distribution, like real charts). Watch the top 10 race with exact counts on the left and a Count-Min Sketch with 4 rows on the right. Red bars are songs the sketch wrongly ranks in the top 10.
Why does a Count-Min Sketch never underestimate?
Why is merging each shard's local top K exact when counts are sharded by item id?
How do you get "top songs in the last hour" without rescanning events?
How expensive is "what's my rank?" among 50M players?
| Resource | Why |
|---|---|
| Sorted sets (Redis docs) | Leaderboards with ZADD, ZRANK and ranges. |
| Count-min sketch (Redis docs) | The sketch, its parameters and commands. |
| Design top-K (Hello Interview) | Another walkthrough, for top-K videos. |