- Book: System Design Pocket Guide: Interviews
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You say "Redis sorted set" in the first thirty seconds, and the interviewer nods. Then comes the follow-up: 50 million players, scores changing thousands of times a second, and every player sees their exact rank on the results screen. Where does your one sorted set live, and what happens when it stops fitting?
That second question is the interview. The first answer is correct, and it is also the part every candidate gets right. What follows is the design the way an interviewer pulls it out of you, one probe at a time, with commands you can paste into a local redis-cli.
Start with the numbers, not the database
Pin down the load before you pick anything. Say the interviewer gives you 50M registered players and a peak of 20,000 score updates per second. Those are interview assumptions, so write them on the board and move on.
Memory first. A Redis sorted set keeps a skip list and a hash table side by side, per the Redis sorted set docs. One contributor on a 2013 Redis mailing-list thread puts a sorted set entry at 100 to 150 bytes once the set has a few thousand items, and reports roughly 150 bytes per entry for a sorted set with more than 150 million entries. Take 150 bytes as a planning number: 50M × 150 B ≈ 7.5 GB for one leaderboard, before replicas, before a weekly board next to the all-time one. Do not quote that as fact in production; load a sample and run MEMORY USAGE key SAMPLES 0, which samples every nested value instead of the default 5.
Time second. ZADD is O(log N) per member and ZREVRANK is O(log N). log₂(50M) is about 25.6, so each update or rank lookup walks a couple of dozen levels. Per operation that is cheap. The question the interviewer is steering you toward is where all 20,000 of those operations land.
The single sorted set, done properly
Here is the whole leaderboard on one key. It works, and you should show that before you break it.
ZADD lb GT 1200 player:1001
ZADD lb GT 950 player:1002
ZADD lb GT 1430 player:1003
ZADD lb GT 1100 player:1001
ZREVRANK lb player:1001
ZSCORE lb player:1001
ZRANGE lb 0 99 REV WITHSCORES
GT (since Redis 6.2) only updates an existing member when the new score is higher, so the fourth line is a no-op and a late, lower submission cannot overwrite a personal best. ZREVRANK returns a 0-based rank with the highest score at 0. On Redis 7.2 and later you can drop the ZSCORE call and write ZREVRANK lb player:1001 WITHSCORE, an option the 7.2 release-candidate announcement lists as new. ZRANGE ... REV is the modern spelling of ZREVRANGE; the docs say ZRANGE can replace it from 6.2 onward.
The interviewer will now ask about ties. Redis orders equal scores by member name, byte by byte, and REV flips that, per the ZRANGE docs. Alphabetical tie-breaking is not a game rule anyone wants. The fix is a composite score: points in the high bits, "who got there first" in the low bits. Scores are doubles, and the ZADD docs say integers are exact only up to 2^53. So budget the bits: 27 bits of points (up to 134,217,727) and 26 bits of time (67,108,864 seconds, about 776 days of season).
score = points * 2^26 + (2^26 - 1 - seconds_since_season_start)
Earlier achievers get a larger remainder, so they rank higher at equal points. Say the bit budget out loud. It is the detail that shows you have read the docs.

One key lives on one node: every write and every rank query for the board converges there.
Why one key stops being enough
Probe: "What breaks at 50M?" Be honest here: on these numbers, maybe nothing yet. A 7.5 GB board fits in one primary's memory, and each operation is a couple of dozen steps. An interviewer who hears you shard straight away will ask whether you checked. Say that one key can hold this board today, then name what would push you off it.
Memory. One key is one hash slot, and in Redis Cluster a hash slot is served by a single node when the cluster is stable. Adding nodes does not split a key. The 7.5 GB board has to fit on one primary, and so does its growth: more players, bigger peaks, and the weekly or regional boards that tend to appear next to the all-time one. Today's size fits; the memory argument is headroom.
Throughput. The same rule sends all 20,000 writes per second to that one primary. Whether one node holds that peak is something you measure with redis-benchmark on your own hardware. Replicas can absorb reads, which the cluster spec covers under scaling reads with replica nodes, but replica reads can lag behind the primary, and "your rank" right after a match is exactly the read where a stale answer looks like a bug. Writes have nowhere else to go. That is the hot key. The command is fast; the problem is that every write has the same address.
Failure. One primary is also one failure domain. While the cluster promotes a replica, writes to the whole board wait.
The interviewer wants to hear you name these limits, say which one you expect to hit first, and only then pick a way to split.
Split option one: shard by player
Hash each player to one of N sorted sets. Writes spread evenly, because hashing ignores score. The trick is getting exact rank back. A player's rank is the number of players with a strictly higher score, and ZCOUNT answers that per shard in O(log N) without walking the range. Ask every shard, add the answers.
import heapq
import zlib
import redis
r = redis.Redis(decode_responses=True)
SHARDS = 16
def key(shard: int) -> str:
return f"lb:s{shard}"
def shard_of(player: str) -> int:
return zlib.crc32(player.encode()) % SHARDS
def submit(player: str, score: int) -> None:
r.zadd(key(shard_of(player)), {player: score}, gt=True)
def rank(player: str) -> int | None:
score = r.zscore(key(shard_of(player)), player)
if score is None:
return None
pipe = r.pipeline(transaction=False)
for s in range(SHARDS):
pipe.zcount(key(s), f"({score}", "+inf")
return sum(pipe.execute()) # 0-based
def top(n: int = 100) -> list[tuple[str, float]]:
pipe = r.pipeline(transaction=False)
for s in range(SHARDS):
pipe.zrange(key(s), 0, n - 1, desc=True,
withscores=True)
rows = [row for part in pipe.execute() for row in part]
return heapq.nlargest(n, rows, key=lambda x: x[1])
zlib.crc32 instead of Python's hash() because hash() on strings is randomised per process, and two app servers would disagree on where a player lives. The ( prefix makes the lower bound exclusive, so equal scores share a rank. Use the composite score above if you need a strict order.
The client above talks to a single node, so it only demonstrates the math. In production the shards live on different primaries behind a cluster client. The cost the interviewer will push on: every rank lookup now fans out to N shards. With 16 shards that is 16 cheap O(log N) calls in one pipeline round trip on a single node, more round trips in a cluster. Top 100 is a scatter-gather: take the top 100 from each shard and merge 1,600 rows. It stays exact, and read cost grows with N.

Shard by player: writes spread evenly, and the top list is a merge of every shard's top.
Split option two: shard by score range, or give up exactness
Range sharding puts scores 0–999 in one key, 1,000–4,999 in the next, and so on. Rank becomes "count everyone in the bands above mine, plus my ZREVRANK inside my band". Neighbours are cheap because they usually sit in the same key, except near a band boundary.
The interviewer's next question is the skew. Scores are rarely uniform: in many games most players cluster near the bottom, so the low band holds most of the members, while the top band is small but takes the most frequent updates from your most active players. You end up re-cutting bands as the season moves. A player crossing a boundary also needs ZREM from one key and ZADD to another; on different nodes those are two writes with no shared transaction, so for a moment the player is counted twice or not at all. For a leaderboard that is usually acceptable. Say so, and say why.
The third answer is the one that impresses: nobody at rank 31,402,118 cares whether they are 31,402,118 or 31,402,500. Keep an exact sorted set only for the top 100,000, and track everyone else in a histogram of score buckets:
HINCRBY lb:hist 1200 1
HINCRBY lb:hist 1100 -1
HGETALL lb:hist
On every improvement, decrement the old bucket and increment the new one. Rank is the sum of the bucket counts above the player's bucket, which the app can cache for a second and serve as "top 3%". Exact where it is visible, approximate where it is not.
Top 100 plus your neighbours
The results screen asks for two things at once. The top 100 is the same for everyone, so do not compute it per request: refresh it once a second into its own small key with ZRANGESTORE or into an app cache, and serve every results-screen read from that copy. ZRANGESTORE reads one source key, so in the player-sharded design write the merged result of top() into that key instead.
Neighbours are per player. With the player's rank k inside a key, ZRANGE by index is O(log N + M), so five above and five below is cheap:
ZREVRANK lb player:1001
ZRANGE lb 7 17 REV WITHSCORES
If player:1001 sits at rank 12, then 7 to 17 is the window around it. Avoid BYSCORE ... LIMIT with a large offset for this; the ZRANGE docs warn that a big offset can add up to O(N). In the player-sharded design, neighbours come from a score window instead: ask each shard for the few members just above and below the player's score, then merge.
If this was useful
The System Design Pocket Guide: Interviews walks 15 real system designs in the order an interviewer probes them: the numbers, the simple version, then what breaks it and the trade-off you defend. If you have an interview loop coming up, it is the short book to carry to it. I also build Hermes IDE, an IDE for developers who ship with Claude Code and other AI coding tools, so weigh this with that in mind.

Top comments (0)