Design a Leaderboard
Design a real-time game leaderboard: score updates, the top 10, any player's rank and neighbours among 50 million, sharding, durability and ties.
On this page
You finish a match in a mobile game and the screen says "You are now #48,213 this week, up 2,904 places", with the three players just above and below you. Another tab shows the top 10 in the world, updating as you watch.
Interviewers like it because the first answer, ORDER BY score DESC, works for a thousand players and fails for fifty million: a rank means counting everyone above you. It tests whether you know a structure that counts in O(log n), keep an in-memory board honest about durability, and can shard one global order. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.
Clarify the problem
Design a leaderboard for an online game.
What is a player's score: their best single match, or points that add up over matches? And which boards: all-time only, or also daily and weekly?
Best match score. All-time, weekly and daily boards.
How many players, and how often do scores change?
50 million players on the all-time board, 10 million play each day, about 10 matches each.
Then I need three reads: the top 10, my own rank, and the players around me, all fresh within a second or two. Scores come from our game servers, not from the phone, so cheating is a separate problem. Ties go to whoever reached the score first. A lost score is worse than a slow one, so the ranking can live in memory but the score itself must be durable.
- Functional: record a match score; keep each player's best score on all-time, weekly and daily boards; show the top 10; show a player's rank and the 5 players above and below.
- Non-functional: a new score shows in rankings within about a second; reads in under 50 ms; no accepted score is ever lost; ties broken by who reached the score first; boards stay readable when a node fails.
- Out of scope: anti-cheat, friends-only boards (a follow-up), rewards for top players.
Estimate the scale
- Score writes: 10 million × 10 = 100 million matches a day ÷ 86,400 ≈ 1,200 a second, about 5,800 at a 5x evening peak. Each one updates three boards: 3 × 5,800 = 17,400 board writes a second at peak.
- Reads: assume 3 leaderboard views per match: 300 million a day, about 3,500 a second, 17,000 at peak.
- Rank cost: in a balanced structure a lookup takes about log2(50 million) ≈ 26 steps. Counting everyone above a player in a sorted list takes up to 50 million.
How much memory does the all-time board need if it lives in an in-memory sorted structure?
- 50 million players
- About 100 bytes per entry, counting the player id, the score, and the index and hash-map overhead
Show the working
- 50 × 106 × 100 B = 5 × 109 B = 5 GB
- Weekly (at most 50 million, usually fewer) and daily (about 10 million, 1 GB) boards add a few GB more
Estimate 5 GB (anything from 1 GB to 20 GB is a good answer)
It fits in one machine's memory with room to spare. Sharding is for throughput or many games, not size.
At peak, how many sorted-set operations per second hit the store? Count 17,400 board writes, plus 2 operations (rank, then neighbours) for each of the 17,000 reads; the top 10 is cached.
- 17,400 board writes a second at peak
- 17,000 reads a second at peak, each needing 2 operations
- Top 10 is served from a 1-second cache
Show the working
- Reads: 17,000 × 2 = 34,000 operations
- Total: 17,400 + 34,000 = 51,400 a second
Estimate 51 thousand/s (anything from 20 thousand/s to 150 thousand/s is a good answer)
If one in-memory node does about 100,000 such operations a second (an assumption worth stating), one primary is half busy. Read replicas give headroom.
Write the calls: who submits a score, how a client reads the top 10, and how it reads "my rank and the players around me". What happens if the same score is submitted twice?
Compare with a model answer
The game server, not the phone, submits a score with a match id, so a retry is recognised and ignored. The phone reads top and around me for a named board. Since the stored value is the best score, applying the same score twice changes nothing anyway.
Define the API
POST /scores game servers only (service credentials)
body: { "match_id": "m_9f2", "player_id": 42, "score": 4200, "ended_at": "..." }
200 { "best": { "all_time": 5100, "weekly": 4200, "daily": 4200 } }
200 same body for a repeated match_id (no change)
GET /boards/{board}/top?limit=10 board = all_time | weekly | daily
200 { "entries": [ { "rank": 1, "player_id": 7, "score": 9930 }, ... ],
"as_of": "2026-11-14T18:00:01Z" }
GET /boards/{board}/players/{id}?around=5
200 { "rank": 48213, "score": 4200, "above": [ ...5 ], "below": [ ...5 ] }
404 the player has no score on this board yetThe decision worth arguing is who may write a score. If phones post their own scores, anyone with a proxy can claim 99,999. The game server, which ran the match, submits it with a match_id. That id also makes retries safe, which matters more once boards keep sums instead of bests.
What is the durable record, and what structure answers "rank of player 42" in milliseconds? Are they the same store?
Compare with a model answer
Two stores. The durable record is a database row per player per board with the best score and when it was reached. The ranking is an in-memory sorted set per board, ordered by score, that counts how many players are above any entry in O(log n). The sorted set can be rebuilt from the rows.
Model the data
-- source of truth: a database, partitioned by player_id
best_scores
board_id VARCHAR(24) -- "all_time", "weekly:2026-W46", "daily:2026-11-14"
player_id BIGINT
score INT
reached_at TIMESTAMP -- for ties: earlier wins
updated_at TIMESTAMP -- indexed, for replays after a failover
PRIMARY KEY (board_id, player_id)
matches_applied -- each match counted once
PRIMARY KEY (player_id, match_id) -- same partition as the player's rows,
-- so one local transaction covers both
-- ranking: one in-memory sorted set per board
weekly:2026-W46 member = player_id, sort key = score with tie-break (see deep dive)The access patterns split cleanly. A score update writes one player's row: any database with a conditional update ("set 4200 if higher") works, partitioned by player. A rank is a query over everyone: SELECT COUNT(*) WHERE score > 4200 walks every higher entry in the index, up to 50 million. So rankings live in an in-memory sorted set, as found in stores such as Redis, which answers rank in O(log n). The database is the truth; the sorted set is a fast, rebuildable view of it.
Sketch the write path from a finished match to the boards, and the read path for "my rank". Which store is written first?
Compare with a model answer
Game server, score service, database first (durable, deduplicated by match id), then the sorted sets. Reads go phone, leaderboard API, sorted sets. Writing the database first means a crash can only leave the sorted set behind, which a replay fixes.
High-level design
The score service makes each score durable, then updates the in-memory boards that every read uses.
- weekly best
- 3,100
1Player 42 finishes match
m_9f2with 4,200 points. The game server, which refereed the match, posts the score.- weekly best
- 4,200
2One transaction: record
m_9f2as applied, and raise each board's row only if 4,200 beats it. Weekly and daily rise; all-time stays at 5,100.- weekly best
- 4,200
3Then the sorted sets: set 42's weekly and daily entries to 4,200 only if higher. About 26 steps each in a set of millions; microseconds.
- weekly rank
- 48,213
4The app asks for 42's weekly rank. The sorted set counts entries above 42 without visiting them, using the counts stored in its index: rank 48,213.
- weekly rank
- 48,213
5A second call reads the range from rank 48,208 to 48,218: the 5 players above, 42, and the 5 below. Two operations, both O(log n) plus the 11 entries returned.
- weekly best
- 4,200
6The game server's request timed out on its side, so it retries
m_9f2. The match is already applied, so the database is unchanged. The service still re-sends the stored bests to the sorted sets, which is harmless and repairs a crash between the two writes.7Failure: the sorted-set primary crashes. A replica takes over, but replication is asynchronous, so it may be missing the last fraction of a second of updates.
8The score service replays the best scores changed in the last few minutes from the database. Applying "set if higher" twice is harmless, so the replay can overlap freely.
Deep dives
The sorted-set structure
A sorted set is two structures kept in step. A hash map from player to score answers "what is 42's score?" in O(1). An ordered index (a skip list or a balanced tree) keeps entries sorted by score. The trick that makes rank cheap: every link in the index also stores how many entries it jumps over. To find a rank, walk from the top and add up the jumps you take. You never visit the entries you skip.
A skip list is a sorted linked list with express lanes: level 0 links every entry; each higher level links a random subset, about half of the level below. Here is one with six players, highest score first. The number on each link is its span.
rank = 0; node = head; level = topwhile level >= 0: while node.next[level] scores higher than target: rank += node.span[level]; node = node.next[level] level -= 1return rank + 1A B C D E F L2 head 870 760 L1 head 950 870 760 L0 head 980 950 900 870 820 760 - target
- 900
- rank
- 0
1The board, highest first: A 980, B 950, C 900, D 870, E 820, F 760. Level 2 links head to D (span 4) and D to F (span 2). Level 1 links head, B, D, F with span 2 each. We want C's rank.
A B C D E F L2 head 870 760 L1 head 950 870 760 L0 head 980 950 900 870 820 760 - target
- 900
- rank
- 0
2On level 2 the next entry is D with 870. That is lower than 900, so jumping there would pass C. Drop a level instead.
A B C D E F L2 head 870 760 L1 head 950 870 760 L0 head 980 950 900 870 820 760 - target
- 900
- rank
- 2
3On level 1 the next entry is B with 950, higher than 900. Jump to B and add the link's span: 2 players (A and B) are above C.
A B C D E F L2 head 870 760 L1 head 950 870 760 L0 head 980 950 900 870 820 760 - target
- 900
- rank
- 2
4From B, the next entry on level 1 is D with 870. Lower than 900, so drop to level 0, still at B.
A B C D E F L2 head 870 760 L1 head 950 870 760 L0 head 980 950 900 870 820 760 - target
- 900
- rank
- 3
5On level 0 the next entry is C itself, which does not score higher than 900. Level 0 is done, so the loop ends: 2 players are above C, and C's rank is 2 + 1 = 3.
A B C D E F L2 head 870 760 L1 head 950 870 760 L0 head 980 950 900 870 820 760 - rank
- 3
- links looked at
- 4
6We looked at 4 links and skipped A without visiting it. With 50 million players, about 26 levels and a couple of looks per level, so roughly 50 steps instead of 48,212.
Updating a score is "remove the old entry, insert the new one", each O(log n), fixing spans on the way. A balanced tree that stores subtree sizes gives the same costs; the skip list is popular because it is simple to make fast in memory.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Database with ORDER BY and COUNT | No new infrastructure; correct and durable. | Counting players above you scans up to 50 million index entries. Fine for the top 10, hopeless for a player ranked 30 millionth. | Up to tens of thousands of players. |
| In-memory sorted set Usual default | Exact rank, top N and neighbours, each O(log n), in microseconds. | Lives in memory, so it needs a durable source to rebuild from; one set is one node's throughput. | Millions of players with exact ranks. The usual answer. |
| Score-bucket counts | Keep a count of players per score value. Rank is the sum of counts above your score, done with a Fenwick tree in O(log of the score range). Tiny and easy to shard. | Gives your position among scores, not your neighbours' names. Ties inside a bucket need extra work. | Huge boards where "you are about #48,000" is good enough below the top. |
Sharding a sorted set
Say it first: 5 GB and 51,000 operations a second fit on one primary with read replicas. Shard when you outgrow that, at 10x or with hundreds of boards. Two schemes compete.
- Shard by player id (hash). Writes spread evenly. The top 10 is a merge: ask each of, say, 8 shards for its own top 10 and keep the best 10 of those 80. That is exact, because every global top-10 player is in their own shard's top 10. A player's rank is a scatter-gather: ask every shard "how many scores are higher than 4,200?" (O(log n) each) and add them up. Neighbours work the same way: ask each shard for its 5 entries just above and just below 4,200, merge, and keep the nearest 5 on each side.
- Shard by score range. Rank is easy: the count of all higher shards plus the rank inside your shard. But the shard with top scores is the hottest, ranges need rebalancing as scores grow, and a player crossing a boundary moves between shards.
Pick hashing by player. Its cost is 8 parallel calls per rank lookup, as slow as the slowest. The cached top 10 is merged once a second, not once a read.
Durability of an in-memory board
An in-memory store can persist itself with an append-only log flushed every second, snapshots, and asynchronous replicas. Each can lose the last moment of writes: up to a second from the log, and whatever a promoted replica had not received.
The better answer is to stop needing the board to be durable. The database row is the truth and is written first. The sorted set is a view that can be repaired:
- After a failover, replay rows whose
updated_atfalls in the last few minutes. "Set if higher" is idempotent and order does not matter, so overlapping replays are safe. - After losing everything, rebuild from the database. At an assumed 500,000 inserts a second, 50 million entries take 100 seconds. Serve the last cached top 10 meanwhile.
- If boards were sums of points, increments would not be idempotent. Apply each match once in the database (the
matches_appliedtable) and write the database's new total into the set, never "add 300" to it.
Daily and weekly boards, and ties
Each period is its own sorted set, with the period in its key: weekly:2026-W46. A new period starts empty because the key changes. When a period ends, copy its top results to the database and let the set expire. Two details:
- Which period does a late score belong to? Use the match's
ended_atfrom the game server, not the arrival time, and keep the previous period open for a few minutes of grace before you declare final results. - Whose period? Pick one clock, usually UTC, and say so. Per-region boards with local midnights are just more keys.
For ties, "earlier wins" must be in the sort key, or the order among equal scores is arbitrary. Pack it into one number: the score in the high bits and (220 − 1 − seconds into the week) in the low bits, so earlier means bigger. A week has 604,800 seconds, which fits in 20 bits (220 = 1,048,576), and scores up to a million also fit in 20 bits. Example: score 4,200 reached 190,800 seconds into the week gives 4,200 × 1,048,576 + (1,048,575 − 190,800) = 4,404,876,975. Forty bits in all, safely below the 53 bits a 64-bit float holds exactly, which matters because some sorted sets store scores as floats. For the all-time board, seconds since launch need about 30 bits for 34 years, so 50 bits in all.
If the product instead wants equal scores to share a rank ("joint 12th"), compute rank as "number of players with a strictly higher score, plus 1", which is a count by score range, not a position.
Bottlenecks and failures
At 10x (58,000 matches a second at peak, about 514,000 sorted-set operations a second), one primary is not enough: shard by player into, say, 8 sets per board and cache the top 10. The database takes 58,000 conditional updates a second, partitioned by player. Most matches do not beat the player's best, so the database can skip unchanged rows and the sorted sets see fewer writes than matches.
| When this dies | What happens | What you do |
|---|---|---|
| A sorted-set primary | Writes to that board pause; a replica is promoted, possibly missing the last moment of updates. | Replay recent rows from the database; reads continue on replicas. |
| All sorted-set copies | No ranks. | Show the cached top 10, rebuild from the database in about 100 seconds. |
| The database | New scores cannot be accepted durably. | Game servers queue scores and retry; match ids make the retries safe. Reads keep working. |
| A score service instance | Its in-flight request fails between the two writes. | The game server retries; the database already has the match, so the retry only re-sends the bests to the sorted sets. Never return early here. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Stores scores in a table and uses ORDER BY; adds a cache when asked about load. |
| Mid | Explains why counting rows is slow, uses an in-memory sorted set with its O(log n) rank, and adds weekly boards as separate keys. |
| Senior | Keeps the database as the truth and the board as a rebuildable view, sizes it to one node before sharding, merges per-shard top 10s correctly, makes updates idempotent, and packs the tie rule into the sort key with the float limit in mind. |
How do you show a player their rank?
From an in-memory sorted set. Its index stores how many entries each link skips, so the rank is a sum along about 26 levels, not a count of 48,000 rows. The board is 5 GB, so one primary holds it. The database stays the source of truth and I can rebuild the set in about 100 seconds.
And at 500 million players?
Shard by player. Top 10 is a merge of each shard's top 10, which is exact. Rank is a parallel count of higher scores on each shard. If that is too slow, exact rank for the top and score-bucket counts for "about #2 million" below.
Follow-up questions
How would you build a friends-only leaderboard?
Do not keep a sorted set per player. Look up the few hundred friends' scores in one batch and sort them on read; sorting a few hundred numbers takes microseconds.
The top 10 must update live on screen. How?
Recompute it once a second and push it to viewers of that screen over server-sent events, or let them poll the cached copy. One computation serves everyone.
How do you stop cheating?
Only game servers write scores, after validating the match. Scores far above a player's history are hidden from public boards until reviewed.
Why not a heap for the top 10?
A heap gives the top k cheaply, but not anyone's rank, and updating an arbitrary player's score inside it needs extra bookkeeping. The sorted set answers all three questions with one structure.
What if a score must be removed after a ban?
Delete the player's row and remove the member from each sorted set. Everyone below moves up by one automatically, because ranks are computed, not stored.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".