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 probe | Answer 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
Write Skew: The Anomaly Snapshot Isolation Does Not Catch
Two transactions each read what the other writes, both commit, and an invariant dies. Snapshot isolation allows write skew — here is how to close the hole.
Designing a Shopping Cart: Price Snapshots, Merge, and No Inventory Hold
A cart is a per-user document, not a reservation. What to store on the line, how to merge an anonymous cart at login, and when the price is allowed to change.
Designing Snowflake-Style IDs: Ordering, Workers, and Clock Rollback
A 64-bit id from a timestamp, a worker number, and a per-millisecond sequence. How many you can issue, why the clock must not step backwards, and why JavaScript cannot hold one.
PostgreSQL 19 on October 29: WAIT for Read-Your-Writes, REPACK Without the Lock, and What Got Pulled
PostgreSQL 19 GA is scheduled for October 29, 2026. The WAIT command gives standbys read-your-writes, REPACK CONCURRENTLY replaces VACUUM FULL and CLUSTER, autovacuum goes parallel, and SQL/PGQ, online checksums, and FOR PORTION OF were reverted in Beta 4.
Idempotency Keys for Write APIs
How to make POST safe to retry: the key, the stored response, the race between two identical requests, and the expiry that forgets too soon.
Read-Your-Writes: The User Just Saved and the Read Replica Does Not Know
Why a write to the primary followed by a read from a replica shows stale data, and the practical ways to pin the next read without sending all traffic to the primary.
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.