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.

⚡ Quick Takeaways
  • 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:1ZADD/ZINCRBY to update, ZREVRANGE for top-k, ZREVRANK for a player's rank, ZRANGE for 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.
tldr

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.

a sorted set, ordered by score
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

core API
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.

OperationSQL (indexed score)Redis sorted set
Update scoreO(log n) index updateO(log n) — ZADD/ZINCRBY
Top-kO(k) with indexO(log n + k) — ZREVRANGE
Player's rankO(n) COUNT scan ✗O(log n) — ZREVRANK ✓
NeighborsHard (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):

Redis operations
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:

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:

bucketed approximate rank
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

takeaway

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.

🎯 interview hot-takes

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).

← previous
Design Nearby / Yelp (Proximity)