SYSTEM DESIGN · DESIGN PROBLEMS · COUNTING AND RANKING · MEDIUM · 34 OF 41

Design a Real-Time Leaderboard: Sorted Sets, Rank Queries and Sharding

How to design a live leaderboard for a game with a hundred million players: why SQL struggles with "what is my rank", how a Redis sorted set answers top 100, my rank and players around me in log time, tie-breaking, daily and weekly boards, sharding past one node, approximate ranks for the long tail, and keeping Redis a rebuildable view over a durable score store.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen first. After each match the player's score updates. They want the global top hundred, their own exact rank, and the five players just above and below them. There are all-time, weekly and daily boards, plus a friends board. Out of scope: matchmaking, cheat detection models and the rewards system.

The qualities. A new score should show up within a second, and rank reads should take under fifty milliseconds at p ninety-nine. No score may ever be lost, even if the fast store crashes. Scores come from trusted game servers, never straight from the client, or the top of the board fills with cheaters within an hour.

Functional
  • submit a score after a match
  • top 100 on a global board
  • my exact rank and score
  • the 5 players above and below
  • boards: all-time, weekly, daily
  • friends board
  • out of scope: matchmaking,
  • anti-cheat models, rewards
Non-functional
  • new score visible in ≤ 1 s
  • rank reads in ≤ 50 ms (p99)
  • scores never lost, even if the
  • leaderboard store crashes
  • only trusted servers write
  • scores, never the client
  • available during big events

Ask scale

Scale. A hundred million players, twenty million daily, ten matches each: two hundred million score updates a day, about two thousand a second, ten thousand at peak. Reads are similar. A sorted set costs roughly a hundred bytes per member, so the all-time board is about ten gigabytes. That fits in memory on one machine.

Back of the envelope
100M players, 20M daily active
10 matches/day → 200M updates/day
  200M / 10⁵ s ≈ 2,000 writes/s
  peak (evening, events) ×5 ≈ 10K/s

reads: 10 board views/day each
  ≈ 2,000 reads/s, peak ≈ 10K/s

sorted set ≈ 100 B per member
100M × 100 B ≈ 10 GB per board

Define API

The API. Score submission is internal, called only by game servers, and carries a match id so retries are safe. Reads are three queries: top N, one player's rank and score, and the k players around them. The board name, like weekly two thousand twenty-six week forty-three, selects which board.

API
internal, game server only:
POST /v1/scores
  { match_id, player_id, score }

GET /v1/boards/{board}/top?n=100
GET /v1/boards/{board}/players/{id}
  → { rank, score }
GET /v1/boards/{board}/players/{id}
    /around?k=5
GET /v1/boards/{board}/friends

Data model

Two layers of data. A durable scores table, keyed by board and player, holds the best score and when it was set. A matches table records which match ids were applied, so a replay does not double-count. On top sits the fast view: one Redis sorted set per board, with player id as the member and points as the score.

Data model
scores (SQL or DynamoDB)
  PK (board, player_id)
  best_score, achieved_at

matches
  PK match_id → applied? (dedupe)

Redis ZSET per board
  key  lb:weekly:2026-W43
  member player_id
  score  points (+ tie-break)

High-level design

Why not just SQL? The top hundred is fine with an index on score. But my rank means counting every player above me. For someone in the middle that is tens of millions of index entries, per request. At ten thousand rank reads a second the database falls over. We need a structure that knows positions, not just order.

The full design. The game server, not the client, decides the score and publishes an event. A score updater consumes it, checks the match id has not been applied, saves the best score to the durable table, then updates the sorted sets for every board that score belongs to. Usually that is all-time, weekly and daily.

Reads are now single commands. Top hundred is a reverse range from zero to ninety-nine. My rank is reverse rank. Around me is a range from five above my rank to five below. The friends board fetches a few hundred scores in one call and sorts them in the API. Each costs log N plus the rows returned.

Why not just SQL?
top 100: index on score, fast

my rank:
SELECT COUNT(*) FROM scores
 WHERE score > :mine
→ walks every row above you
→ 47M rows for a mid player

at 10,000 reads/s: impossible
Reads are one command each
top 100:
  ZREVRANGE lb:all 0 99 WITHSCORES
my rank:
  ZREVRANK lb:all player:42
around me (rank r):
  ZREVRANGE lb:all r-5 r+5
friends (≤ 500):
  ZMSCORE lb:all f1 f2 … → sort

each O(log N + k)

Deep dive: rank

Deep dive one: why rank is cheap. A sorted set is a hash from member to score, plus a skip list ordered by score. Each link in the skip list also stores its span, how many nodes it jumps over. Finding a player walks down about log N levels and adds up the spans on the way. That sum is the rank. For a hundred million players, that is only a few dozen steps.

Inside a sorted set
  • hash: member → score
  • (O(1) score lookup)
  • skip list ordered by score
  • each forward link stores its
  • "span": nodes it skips
  • rank = sum of spans walked
  • on the way down → O(log N)
  • 100M members: a few dozen steps

Deep dive: ties

Deep dive two: ties and time windows. Two players with the same points need a fair order, usually first to reach it wins. Pack that into the score: points shifted into the high bits, and a reversed timestamp in the low bits. Stay under two to the fifty-three, the largest exact integer in a double, which caps points near two million. Each window is its own key with a time to live. Z ADD with the G T flag keeps only a better score.

Ties and time windows
tie rule: same points →
  whoever got there first
score = points × 2^32
      + (2^32 − 1 − epoch_secs)
(fits in a double up to 2^53)

windows = separate keys
  lb:daily:2026-10-24  TTL 3 d
  lb:weekly:2026-W43   TTL 21 d
ZADD GT: keep only a higher best

Deep dive: sharding

Deep dive three: scaling. One Redis node does around a hundred thousand simple operations a second and holds this board easily, so start there, with a replica for failover. You outgrow it at around a billion players, or hundreds of thousands of writes a second. Read replicas help reads first. After that, you must split one board across several nodes.

Two ways to split. Shard by player hash: the top hundred is a merge of each shard's top hundred, but my rank means asking every shard how many scores beat mine and adding them up. Or shard by score range: then my rank is my local rank plus the sizes of all higher shards, which you can cache. The catch is rebalancing ranges as scores drift upward.

A useful trick: players at rank forty-seven million do not need the exact number. Keep exact ranks for the top million, and for everyone else keep a histogram of how many players sit in each score bucket. Their rank is the count in higher buckets plus an estimate inside their own. Show top thirty-eight percent. It is cheaper and kinder.

When one node is not enough
  • one Redis node:
  • ~100K simple ops/s
  • 10 GB board fits easily
  • outgrow it with:
  • 1B players → 100 GB
  • many regional boards
  • or 200K+ writes/s
  • first: read replicas
  • then: shard the board
Two ways to split a board
  • by player hash:
  • top 100 = merge top 100
  • of each shard (easy)
  • my rank = ZCOUNT on every
  • shard and add (fan-out)
  • by score range:
  • shard 0: 0–999, 1: 1000+ …
  • rank = local rank + sizes
  • of all higher shards
  • needs rebalancing as scores grow
Approximate rank for the tail
exact for the top 1M only

everyone else: score histogram
  bucket 0–99:    41,200,000
  bucket 100–199: 22,900,000
  …
rank ≈ players in higher buckets
      + position inside bucket

show "top 38%", not #47,213,908

Trade-offs

Trade-offs. Redis is fast but it is a view, not the source of truth. If the primary dies, a replica takes over and may miss the last second of writes. Replaying the score log from a saved offset fills that gap, and the updater is idempotent by match id. In the worst case, the whole board can be rebuilt from the scores table in minutes.

Monitor the lag from match end to board update, Redis CPU and memory, and the updater's consumer lag. Also watch sudden jumps into the top hundred, a common cheating signal. Before a tournament, expect ten times the writes: scale ahead of time and batch updates with pipelining. Server-side scoring is your first line against cheating.

Bottlenecks and monitoring
  • monitor:
  • event → board lag (≤ 1 s)
  • Redis CPU, memory, p99
  • updater consumer lag
  • sudden jumps in top 100
  • risks:
  • event day → 10x writes
  • → pre-scale, batch ZADDs
  • cheaters → server-side
  • scores, review top 100

Say it like this

In the interview: SQL can sort but cannot cheaply count what is above you, so use a sorted set, a skip list with spans that answers rank in log time. Game servers emit scores to a log, an idempotent updater writes a durable table and the sorted sets. Pack tie-breaks into the score, use one key per time window, and shard by score range when one node is not enough.

Interview summary
  • SQL rank = COUNT(*) → too slow
  • Redis ZSET: skip list + spans
  • top, rank, around me: log N
  • game server writes, not client
  • log → idempotent updater
  • → scores table + ZSETs
  • tie-break packed in the score
  • one key per window, with TTL
  • shard by score range at 10x
  • approximate rank for the tail

Interview questions

Why is a relational database a poor fit for live rank queries?

Finding a player's rank requires counting all rows with a higher score, which is linear in the number of players above them even with an index, so it cannot sustain thousands of rank reads per second.

How does a Redis sorted set compute rank in O(log N)?

It combines a hash from member to score with a skip list ordered by score, and each skip list link stores the number of nodes it spans, so walking down the levels and summing spans yields the rank.

How do you break ties between players with the same score?

Encode the tie-break into the sorted-set score, for example points in the high bits and a reversed timestamp in the low bits, so equal points are ordered by who achieved them first, keeping the total within 2^53 for exact doubles.

How do you support daily and weekly leaderboards?

Keep a separate sorted set per window, such as lb:weekly:2026-W43, update all relevant windows on each score event, and give old window keys a TTL so they expire automatically.

How would you shard a leaderboard that no longer fits on one node?

Sharding by score range lets you compute a global rank as local rank plus the sizes of all higher-range shards, while sharding by player hash makes top-N an easy merge but turns rank into a fan-out count across shards.

What happens if the Redis leaderboard loses data?

Redis is treated as a rebuildable view: a replica takes over, the idempotent updater replays recent score events from the log, and in the worst case the whole board is reloaded from the durable scores table.

How do you prevent cheating on the leaderboard?

Only authoritative game servers submit scores, through an internal API with match IDs for idempotency, and you monitor for anomalies such as sudden jumps into the top ranks for review.

How do you show a rank to a player who is 50 millionth?

Exact rank is often unnecessary in the long tail, so you can estimate it from a histogram of players per score bucket and show a percentile, keeping exact ranks only for the top players.