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.

Case study32 minLesson 8 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

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

The first five minutes
Interviewer

Design a leaderboard for an online game.

You

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?

Interviewer

Best match score. All-time, weekly and daily boards.

You

How many players, and how often do scores change?

Interviewer

50 million players on the all-time board, 10 million play each day, about 10 matches each.

You

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.
Estimate: Memory for the all-time board

How much memory does the all-time board need if it lives in an in-memory sorted structure?

You know
  • 50 million players
  • About 100 bytes per entry, counting the player id, the score, and the index and hash-map overhead

Show the working
  1. 50 × 106 × 100 B = 5 × 109 B = 5 GB
  2. 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.

Estimate: Operations at peak

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.

You know
  • 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
  1. Reads: 17,000 × 2 = 34,000 operations
  2. 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.

Your turn: Define the API

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

HTTP 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 yet

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

Your turn: Model the data

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

Durable rows and in-memory boards
-- 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.

Your turn: High-level design

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.

Writes go database first, then sorted sets. Reads touch only the sorted sets, and the top 10 is cached for a second.Player's app to Leaderboard API (top, around me); Player's app to Game servers (play); Game servers to Score service (POST /scores); Score service to Best scores DB (1. upsert if higher); Score service to Sorted sets (memory) (2. update boards); Leaderboard API to Sorted sets (memory) (rank, range).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)Best scores DBtop, around meplayPOST /scores1. upsert if higher2. update boardsrank, range
Writes go database first, then sorted sets. Reads touch only the sorted sets, and the top 10 is cached for a second.
A new best score, a read, a retry and a crashUse the arrows or ← → keys
  1. A new best score, a read, a retry and a crash, step 1Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)Best scores DB
    weekly best
    3,100

    1Player 42 finishes match m_9f2 with 4,200 points. The game server, which refereed the match, posts the score.

  2. A new best score, a read, a retry and a crash, step 2Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)Best scores DB2 rows raised
    weekly best
    4,200

    2One transaction: record m_9f2 as applied, and raise each board's row only if 4,200 beats it. Weekly and daily rise; all-time stays at 5,100.

  3. A new best score, a read, a retry and a crash, step 3Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)42: 4,200Best scores DB
    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.

  4. A new best score, a read, a retry and a crash, step 4Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)rank 48,213Best scores DB
    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.

  5. A new best score, a read, a retry and a crash, step 5Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)Best scores DB
    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.

  6. A new best score, a read, a retry and a crash, step 6Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)Best scores DBm_9f2 seen
    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.

  7. A new best score, a read, a retry and a crash, step 7Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)failoverBest scores DB

    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.

  8. A new best score, a read, a retry and a crash, step 8Player's app to Leaderboard API; Player's app to Game servers; Game servers to Score service; Score service to Best scores DB; Score service to Sorted sets (memory); Leaderboard API to Sorted sets (memory).Player's appLeaderboard APIGame serversScore serviceSorted sets(memory)caught upBest scores DB

    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.

1 / 8

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 of player C in a skip listUse the arrows or ← → keys
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 + 1
  1. ABCDEF
    L2head870760
    L1head950870760
    L0head980950900870820760
    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.

  2. ABCDEF
    L2head870760
    L1head950870760
    L0head980950900870820760
    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.

  3. ABCDEF
    L2head870760
    L1head950870760
    L0head980950900870820760
    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.

  4. ABCDEF
    L2head870760
    L1head950870760
    L0head980950900870820760
    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.

  5. ABCDEF
    L2head870760
    L1head950870760
    L0head980950900870820760
    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.

  6. ABCDEF
    L2head870760
    L1head950870760
    L0head980950900870820760
    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.

1 / 6

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.

How to answer "what is my rank?"
OptionGood atBad atPick it when
Database with ORDER BY and COUNTNo 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 defaultExact 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 countsKeep 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_at falls 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_applied table) 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_at from 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 diesWhat happensWhat you do
A sorted-set primaryWrites 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 copiesNo ranks.Show the cached top 10, rebuild from the database in about 100 seconds.
The databaseNew scores cannot be accepted durably.Game servers queue scores and retry; match ids make the retries safe. Reads keep working.
A score service instanceIts 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

LevelWhat the answer does
JuniorStores scores in a table and uses ORDER BY; adds a cache when asked about load.
MidExplains why counting rows is slow, uses an in-memory sorted set with its O(log n) rank, and adds weekly boards as separate keys.
SeniorKeeps 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.
Rank among millionsStrong answer
Interviewer

How do you show a player their rank?

You

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.

Interviewer

And at 500 million players?

You

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.

Self-review: leaderboard0 of 12

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure