Design a Real-Time Leaderboard
Run this for someone else. You hold the answers; they do not. Read the prompt, keep the clock, and use the probes below when an answer is thin. Do not show them this page.
Open with this
Rank 300 M players of a mobile game in real time: the global top 100, your exact rank among millions, your friends, and daily, weekly and season boards that reset on time. Take a couple of minutes on requirements, then we will do some numbers, then the design. I will interrupt to keep us moving.
The clock
- 4 min: functional requirements and scope
- 4 min: non-functional requirements, with numbers
- 5 min: back-of-envelope estimates
- 16 min: high-level design and one or two flows
- 16 min: deep dives and the close
Move them on out loud when a section overruns. The commonest failure is spending twenty minutes on requirements and never reaching a deep dive, and preventing that is your job as much as theirs.
Requirements · 8 min
Listen for: a scoped set of capabilities, an explicit out-of-scope list, and numeric targets rather than adjectives. Prompt with “what are you not building?” if they never scope, and “what number would make that requirement real?” if they say “fast” or “highly available”.
Functional (6)
- Submit a score: When a match ends, the player’s score counts toward the boards. Only the best score per window counts, or the total, depending on the game mode.
- Global top N: The top 100 players right now, with scores. Viewed by far more players than appear on it.
- My rank: My exact position among all players ("you are #2,481,903"), plus the players just above and below me.
- Friends leaderboard: Where I stand among my friends, typically fewer than 500 people.
- Time windows: Daily, weekly and season boards that reset at a fixed time, plus all-time. Final standings of a season are frozen and rewarded.
- Out of scope: Matchmaking (see Design a Matchmaking System), the game itself, and reward payouts beyond producing final standings.
Non-functional (6)
- Freshness (a new score visible in < 1 s): Players check their rank right after a match. A board that lags feels broken.
- Read latency (p99 < 50 ms for rank and top N): This is the requirement that drives the hardest trade-off: an exact rank among 300 M players must be computed per request in milliseconds, which rules out counting rows in a database.
- Scale (300 M players · ~6 k score updates/s avg): Season finales concentrate submissions into the last minutes: plan for 100 k updates/s.
- Correctness: A score counts once even if the client retries. Ties break deterministically. Final standings must match what players saw.
- Durability (no lost scores): The in-memory ranking structure can be rebuilt; the scores themselves cannot be lost.
- Integrity: Cheaters target leaderboards directly. Scores come from the game server, never straight from the client, and suspicious ones can be voided.
Estimates · 5 min
Ask for two or three numbers, not all of them. What matters is whether they state assumptions, round sensibly, and say what the number implies. Push once with “where did that come from?”
The numbers (6)
- Score updates per second: ~6 k avg · ~100 k at a season finale. 50 M DAU × 10 matches a day = 500 M/day ÷ 86 400 ≈ 5.8 k/s. In the final minutes of a season, submissions spike about 20×, to roughly 100 k/s.
- Ranking writes per second: ~23 k avg. Each update touches four boards (daily, weekly, season, all-time): 5.8 k × 4 ≈ 23 k/s. One Redis node does ~100 k sorted-set writes a second, so the average fits but the finale does not.
- All-time board memory: ~30 GB. 300 M members × ~100 B (member id, score, skip-list and hash overhead) ≈ 30 GB. Fits one large Redis node, which is why a single sorted set is the starting point.
- Rank lookups per second: ~12 k avg. 50 M DAU × 20 leaderboard views a day = 1 B/day ÷ 86 400 ≈ 12 k/s. Each needs the player’s rank; the top-100 part of the page is shared and cached.
- Cost of one exact rank: ~28 steps. A sorted set keeps a skip list with span counts, so rank is O(log n): log₂(300 M) ≈ 28 steps, microseconds. A SQL COUNT(*) WHERE score > x scans up to 300 M rows.
- Friends board work per view: ~500 lookups. Up to 500 friends: one batched score read (ZMSCORE) of 500 members and a sort in the service. Cheap enough to compute on every view instead of storing per-user boards.
High-level design · 16 min
Let them draw. Interrupt only to ask what backs a component or what a box actually does. Then pick one flow below and ask them to walk it end to end.
Components (13)
- Game client: Plays matches against the game servers and shows leaderboards. Never submits a score itself: anything a client sends can be forged.
- Game servers (authoritative): Run the match and decide the result. At the end they submit a signed result with a unique match id to the score service.
- API gateway: Authenticates players and routes leaderboard reads. Rate limits rank lookups, which bots like to poll.
- Top-N cache (CDN / edge · 1 s TTL): The global top 100 for each board, identical for every player, cached for a second at the edge. Turns millions of identical reads into one per second per board.
- Score service: Validates a match result, records it once per match id, updates the player’s best (or total) per window in the score store, and applies the new value to every board’s sorted set.
- Score store (DynamoDB · key = (board, player)): The durable source of truth: each player’s current value per board, and processed match ids for idempotency. Any sorted set can be rebuilt from it.
- Score events (Kafka): Every accepted score change, for anti-cheat, analytics, the rank histogram and notifications ("you were passed by a friend").
- Anti-cheat: Scores each result against the player’s history and the game’s physical limits. Voids impossible scores and holds high-ranking ones for review before a season closes.
- Leaderboard service: Answers top N, my rank, players around me and friends boards from the sorted sets, falling back to the histogram estimate for ranks deep in the tail when the board is sharded.
- Sorted sets (Redis ZSET per board): One sorted set per board and window (lb:season:s12, lb:daily:2026-09-30…). O(log n) inserts and rank queries. Replicated; rebuilt from the score store on loss.
- Rank histogram (score buckets): Counts of players per score bucket, maintained from score events. Gives an approximate rank ("top 3 %", or about #2.48 M) in constant time when exact ranks would need every shard.
- Friends graph: Each player’s friend list, used to build the friends board on demand.
- Season close job: At the season boundary, freezes final standings from the score store (after anti-cheat review of the top), writes them for rewards, and starts the new season’s keys.
Flows to ask them to walk (5)
- A match ends and the score counts: The write path: authoritative result, exactly-once by match id, best-per-window in the durable store, then the sorted sets. Visible on the board in well under a second.
- The game server decides the match result and submits it with a unique match id.
- The score service records the match id and updates the player’s best per board with a conditional write.
- The new value is applied to each board’s sorted set.
- A score event is published for anti-cheat, the histogram and notifications.
- Open the leaderboard: top 100, my rank, my friends: Three different reads with three different costs: the shared top list is cached at the edge, my rank is one O(log n) query, the friends board is a small batch computed on demand.
- The top 100 comes from the edge cache, refreshed once a second.
- My exact rank and the players around me come from the sorted set.
- The friends board is built from the friend list and one batched score read.
- The last ten minutes of a season: The scale-breaking case: submissions spike to 100 k a second, everyone refreshes their rank, and a single sorted set is now a hot key.
- Submissions surge to around 100 k a second.
- The service batches sorted-set writes and skips no-op updates.
- If one node still cannot keep up, the board is sharded by player and the top is merged.
- Rank reads surge too; the top list is served from the edge and deep ranks from the histogram.
- A Redis node is lost: The sorted sets are a derived view. Losing one costs a short degradation, never a score, because the durable store can rebuild it.
- The primary holding the season board fails; a replica is promoted.
- The score service replays recent accepted scores so nothing is missing.
- If a whole board is lost, it is rebuilt from the score store.
- Reads degrade gracefully while the rebuild runs.
- A cheater reaches #1: The asynchronous integrity path: detect, void, remove, and make sure frozen standings only include reviewed scores.
- Anti-cheat flags a score far beyond what the game allows or the player has ever done.
- It voids the score through the score service.
- The player’s entry is rewritten (or removed) in every affected board.
- At season close, only reviewed scores reach the final standings.
Deep dives · 16 min
Pick two. Ask the headline question, let them answer, then use the follow-ups. The follow-ups are where the level gets decided, so leave time for at least three of them.
Computing a rank fast
Ask: Why not keep scores in Postgres and use ORDER BY and COUNT(*)?
Good answers name: Redis sorted sets, one per board, rebuilt from a durable store, Relational table with an index on score, Recompute ranks in a batch job every few minutes.
Our pick: Keep one Redis sorted set per board and window, with the player id as member and an encoded score (value plus tie-break) as the sort key. ZADD GT applies a new best; ZREVRANK gives the exact rank; ZREVRANGE gives the top N and the neighbours of any rank. The durable truth is a key-value store holding each player’s value per board; the sorted sets are a derived index that can be rebuilt. A single 30 GB set comfortably handles average load; the deep dive on sharding covers the finale.
- How does a skip list give you a rank in O(log n)?
Each forward pointer in a skip list stores a span: how many elements it jumps over. Searching for a member from the top level down, you add up the spans of the pointers you follow; the total when you reach the member is its rank. The number of pointers followed is O(log n) on average. - What if the game ranks by total points rather than best score?
Use ZINCRBY instead of ZADD GT, and make the durable update an increment guarded by the match id, so a retried submission does not add twice. Totals only grow within a window, so the same structure works. - How do you show "the players around me" efficiently?
Get my rank with ZREVRANK, then ZREVRANGE from rank − 5 to rank + 5. Both are O(log n + k), so neighbours cost the same as the rank itself. - Why not use the database’s window functions, like RANK() OVER?
They compute ranks for a whole result set by sorting it, which is fine for a report but not for one player’s rank 12 k times a second. They do the same work as the count, just more elegantly.
When one sorted set is not enough
Ask: The season board takes 100 k writes a second at the finale. One Redis node cannot keep up. How do you split a ranking?
Good answers name: Shard by player; merge shard tops for the top N; exact rank near the top, histogram estimate in the tail, Shard by score range, Exact rank by asking every shard for a count above my score.
Our pick: Start with one sorted set per board and only shard boards that need it. To shard, hash players into N sets. The top 100 is the merge of each shard’s top 100, cached for a second. A player’s exact rank is computed as the sum of ZCOUNT above their score across shards only when they are near the top (say within the top 10,000), where exactness matters. Everyone else gets a rank from a histogram of players per score bucket, maintained from score events, interpolated within their bucket. Write load is also cut by skipping no-op updates (scores that do not beat the player’s best) and by keeping each window’s board on a different node.
- How accurate is the histogram rank?
With 10,000 buckets sized by quantile, each bucket holds about 0.01 % of players, so the estimate is within a few hundred places for a mid-table player. Display it as "about #2.48 M" or "top 3 %", which is both honest and what players care about. - A player’s rank jumps between refreshes because one shard was slightly stale. Fix?
Read all shards from replicas with similar lag, or from primaries for players near the top, and smooth what the client shows (never display a worse rank unless the score dropped). Exact consistency across shards is not worth the cost below the top of the board. - Why not simply buy a bigger Redis node?
Redis executes commands on one thread, so a bigger machine adds memory but not much write throughput. Batching, pipelining and splitting boards across nodes buy more. Sharding a single board is the last step, not the first. - How do you rebalance score-range shards as scores grow?
Move the boundary between two adjacent shards by migrating the members in the band, then switch routing. It works, but the hottest band moves through the season, which is why player sharding plus a histogram is usually simpler.
Daily, weekly and season boards
Ask: The daily board resets at midnight. How do you reset 300 million entries at once?
Good answers name: One key per window (lb:daily:2026-09-30), TTL on old keys, window chosen by a fixed reset time zone, One key per board, cleared by a job at reset, Compute windowed boards from the event log on request.
Our pick: Each board and window has its own key, derived from the score’s timestamp in the game’s fixed reset zone (often UTC, or a per-region board for local resets). The score service writes to every active window’s key; at the boundary new scores simply go to a new key. Old keys get a TTL long enough to show the previous window’s results and to run the close job. Season close reads final standings from the durable store, not from Redis, so expiry never affects rewards. Scores submitted for a match that ended before the boundary but arrived after it count in the window of the match’s end time.
- A match finishes at 23:59:58 and the result arrives at 00:00:03. Which day?
The day the match ended. The submission carries ended_at from the game server, and the window is derived from it, not from arrival time. Close jobs wait a short grace period (say five minutes) after the boundary before freezing results. - How do you support regional boards that reset at local midnight?
Make the region part of the key and compute the window in that region’s time zone. A player belongs to one region per season. The global board stays on UTC. - How much memory do old windows cost?
Each daily board only holds players active that day, so it is far smaller than all-time. With a few days of retention per window type, the extra memory is a fraction of the all-time board. - How do you reward the daily winners exactly at the boundary?
The close job waits for the grace period, then reads the window's final values from the durable store (not Redis), takes the top N with the same tie-break encoding, and writes a frozen standings record. Rewards are paid from that record, so later Redis changes cannot affect them.
Ties and ordering
Ask: Two players both score 48,210. Who is ranked higher, and how does the sorted set know?
Good answers name: Encode score and time into one sortable number, Equal scores share a rank (1, 2, 2, 4), Order by member id.
Our pick: Store encoded = score × 2^32 + (2^32 − 1 − seconds since the season started), so a higher score always wins and, for equal scores, an earlier time gives a larger encoded value. Redis stores scores as doubles, which hold integers exactly up to 2^53, so scores up to 2^21 (about two million) fit with a 32-bit time part; if scores are larger, use a coarser time unit or fewer time bits. The displayed score is encoded ÷ 2^32. The same encoding is used in the score store so rebuilds reproduce the same order.
- What if scores can exceed two million?
Spend fewer bits on time: seconds within a 90-day season need only 23 bits, leaving 30 bits for the score (about a billion). Or store the tie-break in a second sorted structure for the rare ties at the top. Do the bit budget in the interview; it shows you know a double is 53 bits of precision. - Does a later, equal score from the same player move them down?
No: ZADD GT only replaces the member’s score if the new encoded value is greater, and an equal score achieved later encodes lower. The player keeps their earlier timestamp, which is the fair outcome. - Should ties share a rank on screen?
Many games show 1, 2, 2, 4 for fairness. You can still store the time tie-break for a stable order and compute the displayed shared rank by finding the first member with the same displayed score (one extra range query). Decide with the product team; both are easy once the encoding is right. - How do you handle negative scores, such as golf strokes where lower is better?
Invert the value before encoding (for example MAX_SCORE − strokes) so higher encoded still means better, and keep the rest of the scheme unchanged. Never mix ascending and descending boards in one key.
Exactly once, and no cheating
Ask: A client retries a submission, and another client sends a forged 10-million-point score. What stops either from counting?
Good answers name: Server-authoritative results, signed, idempotent by match id; async anti-cheat with voiding; review before rewards, Client submits score with a hash or signature, Hold every score for review before it appears.
Our pick: Only game servers submit results, signed with a service credential, each with a unique match id recorded in the score store in the same transaction as the score update, so retries are no-ops. Anti-cheat consumes score events and checks hard limits, statistical outliers against history and, for top scores, replays. A voided match triggers a recompute of the player’s value from their valid matches and a rewrite of their board entries. Final standings are frozen from the durable store only after the top of each board has been reviewed. For single-player games without servers, validate submitted replays server-side instead of trusting scores.
- A cheat is found a week after the season closed and rewards were paid. What now?
Standings are recomputed from the store without the voided matches, the reward is revoked from the cheater, and the players below move up and receive the difference. Because every standing is derived from recorded matches, this is a recompute, not manual surgery. - How would you know anti-cheat is working?
Track the rate of voided scores, how long voided scores stayed visible, and player reports of suspicious top entries. Review a sample of top scores manually each season to measure what the automated checks miss. - How do you stop bots polling everyone's rank to scrape the board?
Rate limit rank lookups per player and per IP at the gateway, only serve ranks for the authenticated player and their friends, and serve the public top list from the edge cache. Scraping the full board then requires many accounts, which is detectable. - What if a game server itself is compromised?
Each server has its own credential with a narrow scope, so a stolen one can be revoked without touching the rest. Anti-cheat still checks every result for plausibility, and per-server anomaly detection (one server producing many top scores) catches a compromised host quickly.
Close · 5 min
Ask what breaks first at ten times the load, and what they would build next. Then give them your read: one thing that was strong, one thing that was missing, one thing to practise. Be specific; “good job” helps nobody.