← Bank
System Design

Design a Leaderboard

System DesignMid~30m
system-designcachingsorted-sets

Prompt

A mobile game team wants a leaderboard. Players earn points as they play, and the game needs to show "the top players" and "where I rank." They've shipped the game already; they just bolted a database query onto it and it's falling over. Design something that holds up.

How this round runs

"Top players" and "where I rank" sound simple, but the brief hides how big, how fresh, and how the two queries differ. You drive: surface those, then design it, and I'll push you on a player's own rank at scale and make you commit to real-time vs batch and own the cost.

Model answer

1. Requirements I'd surface first.

  • How fresh must it be? "Top players, real-time as I play" and "top players, refreshed hourly" are completely different systems. Real-time means every score update re-ranks; batch means I can recompute a snapshot on a schedule. I'd surface this because it's the single biggest fork.
  • How many players, how many updates/sec? Say 50M registered, 5M daily-active, peak ~10k score-updates/sec. That sizes the data structure and tells me whether a "sort the table" approach is even viable (it isn't at this write rate).
  • Which queries, exactly? Top-K (K=100?) and a specific player's own rank — those have very different costs and people forget the second one is the hard one.
  • Global only, or also per-region / per-friends / per-season? That multiplies the number of ranked sets.
  • Ties? Two players on the same score — stable order by who-got-there-first?

Non-functional: top-K read is hammered (every player opens the leaderboard screen), so it must be O(log n)-ish and cacheable; a player's own rank should resolve in low ms.

2. High-level design.

score event --> ingest API --> sorted set (Redis ZSET) keyed by leaderboard
                                     |                 |
                          ZREVRANGE 0..K (top-K)   ZREVRANK player (own rank)
                                     |
                        periodic snapshot --> durable store (recovery + history)

The core structure is a sorted set (Redis ZSET — a skip list + hash). It gives O(log n) insert/update, O(log n + K) top-K via ZREVRANGE, and — crucially — O(log n) rank of an arbitrary member via ZREVRANK. That last one is why a sorted set beats "keep a sorted array": an array can't answer "what's player X's rank" without scanning. I'd back it with periodic snapshots to a durable store for crash recovery and historical seasons.

3. Deep-dive: top-K and a player's own rank at scale. Top-K is the easy half — ZREVRANGE leaderboard 0 99, and I'd cache the result with a short TTL because thousands of players request the same top-100 and it changes slowly at the head. The hard half is a specific player's own rank. ZREVRANK is O(log n) in one ZSET — fine up to a point. But two things break it at scale:

  • The set outgrows one node. 50M members in one ZSET is memory-heavy and a single hot key. Sharding by score range lets me parallelize, but then a global rank = "my rank within my shard" + "count of everyone in higher shards", which needs per-shard counts maintained as scores move across shard boundaries.
  • Exact rank for tens of millions is rarely needed. Nobody ranked 4,002,118th needs the exact integer. So I'd serve approximate rank outside the top tier — bucket scores into a histogram and answer "you're in the top ~12%", reserving exact ZREVRANK for players near the top where the precise number matters.

4. A committed trade-off and its cost. I'd commit to real-time updates via the sorted set for the live global board, plus a batched nightly job for historical/seasonal boards. The cost I accept out loud: the live board is authoritative and always fresh, but I pay for it with memory (the whole active set in RAM) and a hot write key under peak load — if a single ZSET key becomes the bottleneck, I accept sharding complexity (cross-shard rank math) as the price of keeping rank queries fast. The alternative — recompute-on-read from the database — is simpler and cheaper to store but turns every "where do I rank" into an O(n log n) sort, which is exactly the query that's already toppling their current setup. So I'm explicitly trading memory + write-hotspot for read speed, because the read is the product.

5. Operational concerns. Failure mode: the Redis node holding the ZSET dies and the in-memory ranking is gone. I'd detect it on the node's health/replication lag and recover from the latest snapshot in the durable store, replaying score events since the snapshot from the ingest log so I don't lose recent points. To bound data loss I'd snapshot frequently and keep a replica for fast failover. Rollback for a bad scoring change: scores are events, so I can recompute the board from the event log rather than trusting a corrupted live set.

Signals — what a strong answer shows
  • Surfaced freshness (real-time vs batch), scale, and the two distinct queries before designing
  • Picked sorted set and justified it specifically by O(log n) arbitrary-member rank, not just 'Redis is fast'
  • Identified own-rank-at-scale as the hard part and offered approximate rank for the long tail
  • Committed to real-time + batched-history and named the cost (memory + hot write key)
  • Gave a node-loss recovery path from snapshot plus event replay
Follow-ups — where it goes next
  • 'How do you get a player's own rank when there are 50M players?' → ZREVRANK is O(log n), but shard by score and sum higher-shard counts, or serve approximate rank for the long tail
  • 'What breaks at 10x updates/sec?' → the single ZSET key is a write hotspot; shard by score range and maintain per-shard counts
  • 'Why not just ORDER BY score in Postgres?' → that's an O(n log n) sort per query — the exact thing already failing; commit to the sorted set and own the memory cost
  • 'How do you handle ties deterministically?' → encode a tiebreaker (timestamp) into the score so order is stable