Scope the requirements
This is a data-structure question wearing a system design costume. The interviewer wants the sorted set fast, then spends the rest of the time on durability, cheating, and what breaks past one node.
Functional — what it must do
- Award points when a player wins a match — writes come from the game server, never the client.
- Show the top 10 players, updated in real time.
- Show any player's exact rank, plus the players around them (say ±4 positions).
- Monthly reset — each month is a fresh tournament with its own board.
- Explicitly cut: multiple game modes and friends-only boards — both come back cheaply as extra keys, and I'd say so.
Non-functional — the numbers that shape the design
- Real time: a win is visible in the rankings immediately — no hourly batch recompute.
- Rank and top-10 reads in single-digit milliseconds — this sits on a game results screen.
- Scale: 5M DAU, 25M MAU — every monthly player holds a score.
- Scores are durable: Redis losing power must not lose anyone's points.
The word doing the work in the requirements is rank. Top 10 is easy in anything; the exact rank of player 12 million, in milliseconds, while scores change hundreds of times a second — that's what kills SQL and picks the data structure for me.
Back-of-envelope estimates
The arithmetic here delivers a punchline: the whole leaderboard fits in one Redis instance with room to spare, so the design challenge is correctness and durability, not throughput.
| Quantity | Estimate | How you got there |
|---|---|---|
| Score updates | ~600/s | 5M DAU × 10 matches/day ÷ 86,400 ≈ 580; peak 5× ≈ ~3,000/s. |
| Top-10 reads | ~60/s | roughly once per user per day: 5M ÷ 86,400 ≈ 58, peak ~300. Reads are almost negligible. |
| Members per board | 25M | one entry per monthly active player. |
| Raw data size | ~650 MB | 25M × (~24 B id + score + overhead) — a few GB with skiplist overhead; one node fits it. |
| Redis headroom | ~100k ops/s | peak load is ~3k ops/s against ~100k a node handles — 30× headroom before sharding is even a conversation. |
Three thousand updates a second against a structure that does a hundred thousand, and a few gigabytes against a box with sixty-four — one Redis node carries this entire product. I'll design the sharded version too, but I want it on record that it isn't needed at 25 million users.
Sketch the API
Three endpoints. The security decision is the interesting one: the write path is internal-only, because a leaderboard that trusts clients is a cheating leaderboard.
?surrounding=4 adds the four players either side.- The client never reports its own score. The game server validates the match outcome and calls the internal write endpoint; the public surface is read-only. Saying this unprompted is worth points — accepting client-submitted scores is the most common instant-fail in this question.
- Rank responses carry profile data (name, avatar) so clients make one call — but that data is hydrated at read time from the profile store, never stored in the ranking structure.
Data model
One Redis sorted set per month is the entire serving model; a boring relational table behind it is the source of truth. Keeping those two roles separate is the design.
Redis sorted sets, without hesitation — the structure is a hash plus a skip list, so score updates and rank lookups are O(log n) and range reads are O(log n + m), which is precisely this question's access pattern as native commands. MySQL sits behind it as the system of record, not a serving path. I'd switch — or rather, supplement — only past hundreds of millions of players, where I'd move to sharded sets with approximate global ranks, because exact rank at that scale stops being worth its cost.
High-level design
Two thin services over one sorted set: the game service owns the validated write path, the leaderboard service owns reads. Durability rides an async queue to MySQL so the hot path never waits on disk.
- A match ends → the game client reports the result to the game service, which validates it server-side (the client is never trusted with points).
- The game service runs
ZINCRBY leaderboard:2026_07 1 user123— the ranking is updated in real time, in one O(log n) operation. - It also drops a score event onto Kafka; the persister consumes it and updates the MySQL row — durability without adding disk latency to the game flow.
- A player opens the leaderboard → the leaderboard service runs
ZREVRANGE 0 9 WITHSCORES, batch-fetches the ten profiles, and returns the enriched top 10 in a few milliseconds. - For "where am I?",
ZREVRANKgives the exact rank and one moreZREVRANGEaround it gives the neighbours — the relative leaderboard costs two commands.
Deep dives — where the interview is won
Why SQL can't do this, said with numbers
Rank in a relational table is SELECT COUNT(*) FROM scores WHERE score > x — a scan over a 25M-row index for every rank request, repeated for every player who opens the screen, while the same table absorbs 3,000 updates a second that each invalidate every cached rank below them. Even a covering index makes this thousands of rows examined per query; it's real-time in the demo and dead at launch. Materialised rank columns fare worse: one score change shifts the rank of every player below, so writes become unbounded.
The sorted set solves it structurally: a hash gives O(1) member→score, a skip list keeps members score-ordered so rank is an O(log n) walk with node-span counting, not a count of everything above you. This is the expected opening dive — the interviewer wants the why, not just the word Redis. SQL isn't discarded; it's demoted to the system of record, which is the job it's actually good at.
Redis is the ranking, not the record: durability by rebuild
The failure question is guaranteed: "Redis dies — do we lose everyone's points?" No, because Redis was never the system of record. Every score event lands in MySQL via the Kafka path, so recovery is a rebuild: replay the month's rows into a fresh sorted set with a pipeline of ZADDs — 25M entries load in a couple of minutes. Redis persistence (AOF every second, plus a replica for fast failover) narrows the window so a rebuild is rarely even needed; the MySQL rebuild is the backstop that makes the answer airtight.
The ordering subtlety worth volunteering: during a rebuild or failover there's a brief window where Redis lags MySQL. That's fine — the leaderboard is allowed seconds of staleness under failure; what it's not allowed is permanent loss. Separating those two guarantees explicitly is what makes this section land.
Sharding, honestly: two bad options and when you need neither
Past one node there are two partition schemes, and both cost something real. Range partition by score band (shard 1 holds 0–100 points, shard 2 holds 101–500, …): top 10 reads only the highest shard — cheap — but a player crossing a band boundary must be moved between shards on update, and the bands need rebalancing as the score distribution shifts. Hash partition by user_id (Redis Cluster): updates are trivial and balanced, but top 10 becomes scatter-gather — fetch each shard's local top 10, merge — and exact global rank degrades to a sum of local ranks, an approximation, or a periodic recompute.
The honest close: at 25M members and ~650 MB, one node has 30× headroom on both memory and ops, so I'd run a primary with a replica and skip sharding entirely. Naming the two schemes, their costs, and then declining them on the numbers is a stronger answer than sharding by reflex — over-engineering the easy dimension is a scored mistake here.
Ties, resets, and the composite-score trick
Two players on 87 points — who's ranked higher? Redis breaks ties lexicographically by member, which is arbitrary and feels unfair. The standard fix is a composite score: pack the tiebreak into the number, for example score × 10^10 − last_update_timestamp, so equal points rank by who got there first. It works because ZSET scores are doubles with room to spare at these magnitudes; interviewers like this trick and it takes one sentence to deploy.
The monthly reset costs nothing by design: the month is in the key, so 1 August starts writing leaderboard:2026_08, which begins empty — no mass deletion, no migration. The old key hangs around for "last month's winners", then gets an expiry or an archive to MySQL. Rollover under live writes is a one-line policy: a win at 23:59:59 counts to the month the game service timestamps it into, and a second of fuzziness at the boundary is acceptable for a game.
Follow-ups you should expect
How would you build a friends-only leaderboard?
ZSCORE for each against the month's board, and sort in the service — a few hundred O(1) lookups is sub-millisecond work. Materialising a per-user friends ZSET only becomes worth it if friends-boards dominate traffic.What changes at 500 million players?
Could you do this on DynamoDB without Redis?
How do you stop cheaters?
The top-10 read rate spikes 1000× during a tournament final — problem?
Where candidates lose the room
- Computing rank with SQL COUNT or ORDER BY per request and calling it real time — the scan-per-read cost is the exact thing this question tests.
- Recomputing the top 10 by scanning all users on every read instead of maintaining an ordered structure that already knows.
- Stuffing profile blobs into the sorted set — it bloats 650 MB into many GB and turns every profile edit into a leaderboard write.
- Accepting client-submitted scores — a trust-boundary failure that no data structure can compensate for.
- Treating Redis as the only copy: no durable store behind it means one power cut deletes the month's tournament.