5 min readRishi

Designing a Real-Time Leaderboard: Sorted Sets, Sharded Top-K, and Rank Queries

Leaderboards look like a toy question and hide a sharp asymmetry. "Show the top 10" is cheap at any scale — the head of the distribution is small, hot, and cacheable. "Show my rank" — for player 217,384,922 of 300 million, updating live as everyone scores — is a different computational object entirely: a global order statistic over a write-heavy set. Interviewers pick this question to watch whether you notice the asymmetry and split the design along it.

Requirements

Game with 300M players; scores update at ~50K writes/sec (spiky around events); queries: top-N (heavily read), player's rank + neighbors ("you're #1,247,332 — here are the 5 above/below you"), percentile tiers; freshness within seconds; weekly/seasonal resets.

The single-node answer: sorted sets

Up to tens of millions of members, this is Redis's flagship use case. A sorted set (ZSET) is a hash map + skip list: O(log N) insert/update, O(log N + K) range reads, and — the underrated part — O(log N) rank lookup (ZRANK), because skip-list nodes carry span counts, turning "position in order" into a sum along the search path.

ZADD    lb:s42 15200 player:88            # update score
ZREVRANGE lb:s42 0 9 WITHSCORES           # top 10
ZREVRANK  lb:s42 player:88                # my rank
ZREVRANGE lb:s42 (r-3) (r+3)              # neighbors around my rank

One node at 50K ops/sec is comfortable; memory (~100-150 bytes/member with IDs) puts 300M members at ~40GB — over one box's comfort line, and one partition's failure domain. So the real design starts where ZADD stops being the whole answer.

Sharding: and the rank query breaks

Shard players across M sorted sets by hash(player_id). Writes stay O(log n) local — perfect. Reads split by the asymmetry:

Top-N: fetch top-N from each shard, merge — a K-way merge of M sorted lists, costing M×N reads for exact results. But since top-N is read millions of times and changes slowly relative to reads, the answer is a materialized head: a background process (or write-through on any score entering head range) maintains a single small "top-1000" set that all top-N reads hit. The head is hot, tiny, and replicated — reads never fan out.

Rank: my_rank = Σ over shards of count(score > mine)ZCOUNT per shard is O(log n), so an exact rank costs M small queries. At M=16 that's fine per-query but multiplies under load, and this is where the senior move appears: nobody needs their 9th digit of rank to be exact. Approximate the tail:

maintain a global score histogram (e.g., 10K buckets, updated by stream):
rank ≈ Σ counts of buckets above mine + position within my bucket (interpolated)
exact for head (top 1000 via materialized set), approximate beyond
error: ±0.01% of population — invisible at rank 1,247,332

One histogram read replaces M shard queries; the histogram updates from the score stream and is periodically rebuilt for drift. Exact-head + approximate-tail is the same "precision where eyes are" budget as ad-click counting, and stating it as an explicit product decision — ranks past 1000 are estimates — is the answer that separates levels.

The write path grows up

At event spikes (10× writes, everyone finishing a match at once), score updates go through a small pipeline rather than raw ZADDs: a stream (Kafka/Kinesis) absorbs the spike; consumers apply conflation — multiple updates for one player within a window collapse to the last/best (leaderboards care about current standing, not history, so intermediate values are droppable — say why conflation is legal here); durable score-of-record lands in a database (the ledger for audits and rebuilds), with sorted sets as the rebuildable serving view. That last sentence carries the recovery story: a lost shard is repopulated from the database + stream replay, not mourned.

Anti-cheat sits in this pipeline too — validation before the score enters the set (server-authoritative scoring, bounds checks, statistical outlier holds) — because removing a fraudulent #1 from a live leaderboard is far messier than never admitting it.

Resets, seasons, and variants

Weekly reset ≠ DEL on a 40GB key (that's a multi-second blocking stall — use UNLINK/lazy free). Cleaner: leaderboards are named by window (lb:2026w29) — new week, new empty set, old ones age out by TTL/archival; "all-time" is just another window. Percentile tiers ("top 5% = Diamond") read off the same histogram as approximate rank. Friends-leaderboards invert the problem: tiny sets (~200 members), computed per request from friends' scores — no global order needed, no shard fan-out; the same query over a different graph is nearly free (the nearby-friends lesson again: fan-out cost is a property of the graph, not the feature).

Interview probeAnswer sketch
Why not ORDER BY score DESC in SQL?50K writes/sec of index churn + rank = offset scan (O(N)); B-trees don't carry span counts
Two players, same score?Composite score (points << 20) - timestamp — deterministic tiebreak inside one sort key, no second sort dimension
Score update lost?Stream is the source; ZSETs rebuildable; conflation makes replay idempotent-enough (last-write per player)
Global + per-region boards?Write once to stream; consumers project into region/global/mode views — one fact, many materialized orders

The shape worth keeping: an ordered index you can afford is a serving view; the truth is a stream + ledger behind it. Once ranks past the head are declared approximate, every scaling problem in this system becomes pleasantly ordinary.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

  • No comments yet. Be the first.