"Design a leaderboard" looks trivial until you ask how a player learns they are ranked 4,812,377th out of ten million — instantly, while scores keep changing. That single requirement rules out the naive "store scores in a table and ORDER BY" answer and pushes you toward the right data structure. It is a clean, focused prompt that rewards knowing one thing deeply: the sorted set.
Here is a complete leaderboard system design walkthrough with an architecture diagram, following the standard requirements → API → design → data → scale arc.
[ Game / App Client ]
| ^
score event top-K / my-rank
v |
[ API / Leaderboard service ]
| |
ZADD ZREVRANGE / ZREVRANK
v |
+---------------------------+
| Redis Sorted Set (ZSET) |
| member -> score, ranked |
+---------------------------+
|
v (async persistence)
[ Durable store: SQL / KV ] <-- source of truth, replay/rebuild
1. Clarify the requirements
Functional requirements
- Update a player's score (absolute or increment)
- Return the top-K players
- Return a specific player's rank and score
- Optional: a window of players around a given user ("you and your neighbors")
Non-functional requirements
- Real-time: rank reflects a new score within a second or two
- Low-latency reads for top-K and rank-of-user
- Scale to tens of millions of players
- Handle bursty writes during events
Back-of-the-envelope scale: say 10M active players and 1M score updates/minute during peak (~17K writes/sec), with reads an order of magnitude higher. That fits comfortably in memory — 10M entries at ~100 bytes is about 1 GB — which is exactly why an in-memory sorted set is the natural fit.
2. API design
POST /score
body: { "userId": "u123", "delta": 50 } // or absolute score
-> 200 { "score": 4200, "rank": 87 }
GET /leaderboard/top?k=100
-> 200 [ { "userId": "...", "score": ..., "rank": 1 }, ... ]
GET /leaderboard/rank/{userId}
-> 200 { "rank": 4812377, "score": 3300 }
3. The ranking problem
The whole question is really "how do I keep millions of items ordered by a score that changes constantly, and answer both top-K and what is X's rank cheaply?" Consider the obvious options:
| Approach | Update | Rank-of-user | Verdict |
|---|---|---|---|
SQL table + ORDER BY | O(1) write | O(N) count of higher scores | Dies at scale |
| Cached sorted array | O(N) insert | O(log N) binary search | Writes too slow |
| Sorted set (skip list + hash) | O(log N) | O(log N) | The answer |
The SQL approach is fine for a small board but computing rank means counting every player with a higher score — a full scan per request. A sorted set gives you fast writes and fast rank reads at the same time.
4. Sorted sets (Redis ZSET)
The canonical implementation is a Redis sorted set (ZSET). Internally it is a skip list (keeps members ordered by score, supports O(log N) rank and range queries) paired with a hash map (member → score, for O(1) score lookup). That combination is what makes every operation you need cheap:
# update a score (increment)
ZADD board INCR 50 user:123 # O(log N)
# top-10, highest first
ZREVRANGE board 0 9 WITHSCORES # O(log N + K)
# a specific player's rank (0-based, highest = 0)
ZREVRANK board user:123 # O(log N)
# players around a user (rank r): fetch r-2 .. r+2
ZREVRANGE board r-2 r+2 WITHSCORES
No sorting happens at query time — the set is always ordered, so top-K and rank-of-user both ride the existing structure. This one data structure answers every functional requirement, which is exactly why interviewers reach for this prompt.
Interview signal: naming the sorted set and explaining why it beats ORDER BY — O(log N) rank instead of an O(N) count — is the core insight the interviewer is listening for.
5. Ties and composite scores
When two players share a score, Redis orders them lexicographically by member key — stable but arbitrary. If ties need real meaning (say, whoever reached the score first ranks higher), fold the tiebreaker into the score itself. Pack the primary score into the high bits and an inverted timestamp into the low bits of a single number:
compositeScore = score * 1e13 + (MAX_TS - reachedAt)
# higher score wins; equal scores -> earlier timestamp wins
Now a single numeric sort key enforces both rules, and every ZSET operation still works unchanged.
6. Real-time updates and durability
Each score event is a single ZADD, so writes are cheap and the ranked view is always current. Delivery to clients is either polling the top-K every few seconds or pushing over WebSocket/pub-sub for a live board.
Redis is fast but in-memory, so the sorted set is a read/compute cache, not the source of truth. Persist raw score events asynchronously to a durable store (SQL or a KV store) so you can replay and rebuild the ZSET after a restart. Redis persistence (AOF/RDB) plus a replica covers most failure cases.
7. Scaling: sharding and approximate ranks
One Redis node handles a surprising amount, but past its limit or for isolation you shard. Two common strategies:
- Segment sharding. Separate boards per region, tier, or time window (daily/weekly). Often you never need a single global rank — users care about their segment.
- Score-range sharding. Partition the score space across nodes. Top-K reads the top shard; rank-of-user sums the counts of all higher-scoring shards plus the local rank.
Exact global rank across shards is expensive, so large systems return approximate ranks for the long tail: each shard tracks local counts and you sum boundary offsets, accepting small error. Nobody notices whether they are ranked 4.81M or 4.82M — but the top-N is always exact, because that is the part everyone actually looks at.
Key trade-offs the interviewer probes
- In-memory vs durable. Redis gives O(log N) ranked reads but is volatile — you need async persistence and a rebuild path.
- Exact vs approximate rank. Exact rank across shards is costly; approximate ranks for the tail plus exact top-N is the standard compromise.
- Global vs segmented boards. Segmenting sidesteps cross-shard ranking entirely — the cheapest scaling move when the product allows it.
- Tie-breaking. Default lexicographic ties are free; meaningful ties need a composite score.
Framework reminder: every system design answer follows the same arc — requirements → estimates → API → high-level design → data model → scale → trade-offs. Keep the system design cheat sheet in mind and narrate which stage you're in.
Structure any design answer with live AI support
CoPilot Interview surfaces a structured design skeleton — requirements, API, data model, and scaling — in about 4 seconds during real Zoom and Teams calls. Free for Windows and macOS, invisible on screen-share.
Download freeFAQ
What data structure powers a leaderboard?
A sorted set. Redis ZSET is the canonical choice: each member (player) has a score, entries stay ordered by score, and updates, top-K reads, and rank lookups are all O(log N). It is essentially a skip list plus a hash map, giving fast ranked reads and fast score updates in one structure.
How do you get a player's rank efficiently?
With a Redis sorted set, ZREVRANK returns a member's rank directly in O(log N) — no full scan. ZREVRANGE returns the top-K in O(log N + K). Both operations use the sorted set's internal order, so you never sort the whole set at query time.
How do you scale a leaderboard beyond one machine?
Shard the sorted set by score range or by segment (region, tier). Exact global rank across shards is expensive, so large systems return approximate ranks — each shard knows its local counts and you sum boundary offsets — reserving exact ranking for the small top-N that everyone actually looks at.
How do you handle ties in a leaderboard?
Redis breaks ties lexicographically by member key, which is stable but arbitrary. For meaningful tie-breaking (earliest to reach the score), encode a composite score — for example score in the high bits and an inverted timestamp in the low bits — so a single numeric value sorts by both.
How are real-time leaderboard updates delivered?
On each score event the service does a ZADD to update the sorted set. Clients either poll the top-K every few seconds or subscribe over WebSocket/pub-sub for pushes. Because the write is a single O(log N) operation, updates propagate to reads almost immediately.