A leaderboard ranks players by score in real time: show the global top 10, tell me my rank, and show the players just above and below me — all updating instantly as scores change, for millions of players. It looks like a one-line SQL query (ORDER BY score), and that's exactly the trap. The interesting part is that computing a rank efficiently, at scale, under constant updates, is something a naive database does terribly — and a single specialized data structure (the sorted set) does beautifully.
- SQL ORDER BY + COUNT doesn't scale — computing a player's rank means counting everyone above them, an O(n) scan per query, hopeless under heavy reads.
- Redis sorted sets are the answer — a skip-list + hash give O(log n) score updates, top-k, and rank queries out of the box.
- Core operations map 1:1 —
ZADD/ZINCRBYto update,ZREVRANGEfor top-k,ZREVRANKfor a player's rank,ZRANGEfor neighbors. - Redis is the hot store; a DB is the source of truth — persist scores durably and rebuild the sorted set on restart.
- Sharding breaks exact global rank — once the set spans nodes, you can't get a precise cross-shard rank cheaply; use approximation.
- Time-based boards (daily/weekly) are just separate sorted sets with a reset/expiry.
Don't compute rank with SQL — counting everyone above a player is O(n). Use a Redis sorted set, which keeps members ordered by score in a skip list and supports O(log n) updates, top-k, and rank queries. Back it with a durable database as the system of record. A single sorted set handles tens of millions of entries; beyond that you must shard, which sacrifices exact global rank — so switch to bucketed/approximate ranking. Periodic boards are just additional sorted sets.
rank member score
0 alice 9820 ◀ ZREVRANGE 0 2 → top 3
1 bob 9650
2 carol 9510
...
41 you 7300 ◀ ZREVRANK "you" → 41
42 dave 7295 ◀ ZREVRANGE 40 44 → players around you
...
skip-list keeps order; all ops O(log n)
Step 1 — Clarify Requirements
Functional: update a player's score (set or increment); get the global top-k; get a specific player's rank; get the players immediately around a player ("you're #42, here are #40–44"). Non-functional: updates and reads should reflect near-real-time standings, low latency, high availability, and scale to millions of players and high write volume (every game action can change a score). Clarify whether scores only increase (simpler) or can go up and down, and whether ranking is global, per-region, or time-windowed — these change the design.
Step 2 — Capacity Estimation
Say 50M players and 10M daily active. If each active player triggers ~10 score updates/day, that's ~100M writes/day (~1–2K writes/sec average, much higher during peak events) plus heavy reads (everyone refreshing the board). A sorted set entry is small (member id + score), so 50M entries is on the order of a few GB — comfortably in memory on a single large Redis node, which is an important sizing insight: you often don't need to shard at all.
Step 3 — API Design
POST /scores {user_id, delta} # add points
GET /leaderboard/top?limit=10 # top-k
GET /rank/{user_id} # this player's rank + score
GET /leaderboard/around/{user_id}?range=5 # neighbors
Step 4 — Why the Naive Database Approach Fails
Store (user_id, score) in SQL and serve the top-k with SELECT ... ORDER BY score DESC LIMIT 10. With an index on score, top-k is actually fine. The killer is rank: "what is player X's position?" requires SELECT COUNT(*) WHERE score > X.score — counting every player ahead of them. That's an O(n) operation per query, recomputed constantly as scores change, and it collapses under millions of players and frequent "show me my rank" requests. Rank is the operation that demands a better structure.
| Operation | SQL (indexed score) | Redis sorted set |
|---|---|---|
| Update score | O(log n) index update | O(log n) — ZADD/ZINCRBY |
| Top-k | O(k) with index | O(log n + k) — ZREVRANGE |
| Player's rank | O(n) COUNT scan ✗ | O(log n) — ZREVRANK ✓ |
| Neighbors | Hard (need rank first) | O(log n + range) — ZRANGE |
Step 5 — Redis Sorted Sets
A Redis sorted set (ZSET) stores members each with a score, kept ordered by a skip list (plus a hash map from member to score). This gives exactly the operations a leaderboard needs, all in O(log n):
ZINCRBY board 50 "user42" # add 50 points (or ZADD to set)
ZREVRANGE board 0 9 WITHSCORES # top 10 (highest first)
ZREVRANK board "user42" # player's 0-based rank
ZREVRANGE board 40 44 # players ranked 40..44 (neighbors)
ZSCORE board "user42" # a player's current score
Every leaderboard query maps directly to one command, and the skip list keeps everything ordered as scores change — no scans, no recomputation.
Step 6 — Persistence and the Source of Truth
Redis is the fast, in-memory serving layer, but you shouldn't treat it as the only copy of the data. Keep a durable database (SQL or a KV store) as the system of record for scores. On every update, write to the database and update the sorted set (write-through), or stream score events and apply them to both. If Redis restarts or a node is lost, rebuild its sorted set from the database. Redis persistence (RDB snapshots / AOF) helps recovery speed, but the durable DB is the authoritative backup.
Step 7 — Top-k, Rank, and Neighbors
With the sorted set in place, the three core reads are trivial: top-k is ZREVRANGE 0 k-1; a player's rank is ZREVRANK (O(log n) — the structure tracks subtree sizes so it counts predecessors without scanning); neighbors are a ZREVRANK to find the position followed by a ZREVRANGE over the surrounding window. The top-k list is also extremely cacheable — it changes slowly relative to how often it's viewed, so cache it with a short TTL to shield Redis from read spikes during big events.
Step 8 — Scaling: When One Node Isn't Enough
A single Redis node holds tens of millions of entries in a few GB, so for many leaderboards you never shard. When you must (memory or write throughput limits), you shard the sorted set across nodes — and this is where it gets hard, because rank is inherently a global property:
- Shard by score range (e.g. node A: 0–1000, node B: 1001–5000…). Top-k and rank become "ask the top shard(s) and add up counts," but score ranges are skewed and a player crossing a boundary must move shards.
- Shard by user (hash) spreads writes evenly, but now every rank/top-k query must fan out to all shards and merge — expensive.
Step 9 — Exact vs Approximate Global Rank
Across shards, computing an exact global rank for an arbitrary player is costly (you'd query every shard for "how many of yours beat this score?"). At very large scale, the standard move is to approximate:
maintain a count of players per score bucket:
[9000-9999] → 1,203
[8000-8999] → 4,560
[7000-7999] → 12,800 ← your bucket
...
approx_rank(you) = sum(counts of higher buckets)
+ your position within your bucket
→ "top ~3%" instead of "rank 41,233" (cheap, good enough)
Most products only need an exact rank for the top-k (a single, cheap, unsharded "leaderboard of leaders") and an approximate percentile for everyone else ("you're in the top 5%"), which buckets deliver cheaply.
Step 10 — Time-Based and Segmented Leaderboards
Daily, weekly, or seasonal boards are simply separate sorted sets keyed by period (e.g. board:2023-W15), each with a TTL so it expires automatically when the period ends; updates write to the current period's set (and possibly an all-time set). The same pattern gives per-region or per-friend-group boards — just more sorted sets, or a ZINTERSTORE with a set of friend IDs for a "friends only" view.
Step 11 — Key Tradeoffs
- Sorted set vs database. The sorted set makes rank O(log n) instead of O(n); the cost is running and persisting an in-memory store alongside your DB.
- Single node vs sharded. One node keeps exact global rank trivial; sharding scales further but forces fan-out queries or approximation.
- Exact vs approximate rank. Exact rank is feasible unsharded; at massive scale, bucketed percentiles are far cheaper and usually sufficient outside the top-k.
- Freshness vs caching. Caching the top-k shields Redis during spikes at the cost of a few seconds of staleness — almost always acceptable.
The leaderboard is a one-data-structure problem: a Redis sorted set turns the expensive parts (rank, neighbors, top-k under constant updates) into O(log n) operations that a naive SQL approach can't match. Persist to a durable store as the source of truth, cache the top-k, and only reach for sharding — and the approximate ranking it forces — when a single node truly can't hold the set.
Why not just SQL ORDER BY? Top-k is fine, but a player's rank needs COUNT of everyone above them — O(n) per query, which collapses at scale.
Why a Redis sorted set? Its skip-list gives O(log n) score updates, top-k (ZREVRANGE), and rank (ZREVRANK) — exactly the leaderboard operations.
Is Redis the source of truth? No — it's the hot serving layer; a durable DB holds authoritative scores so you can rebuild the set after a restart.
What breaks when you shard? Exact global rank — it spans all shards. Use score-bucket counts to compute an approximate rank/percentile cheaply.
How do daily/weekly boards work? Separate sorted sets keyed by period with a TTL; write to the current period (and optionally an all-time set).