"A mobile game with 1 million daily players needs a live global leaderboard. Scores update constantly, ranks must feel instant, and it has to survive tournament spikes without melting."
"Just store scores in Postgres and ORDER BY score DESC LIMIT 100." That sorts a million rows on every page view โ or leans on an index that must be rewritten on every single score update, turning each write into index churn. A sorted set (Redis ZADD/ZREVRANK) keeps the order incrementally: updates cost O(log n), top-N reads are O(log n + N), and rank is a single lookup. That's the entire data structure decision, and it's the one interviewers are waiting for.
| Assumption | Value |
|---|---|
| Players per popular game | 1,000,000 |
| Bytes per entry (player id + score) | ~64 bytes |
| Memory per leaderboard | 1M ร 64 B โ 64 MB โ fits in RAM easily |
| Write rate (peak) | 20k ZADD/sec per game |
| Read rate | 200k reads/sec across games (top-N + rank) |
| ZADD cost at 1M members | O(log 1M) โ 20 comparisons โ sub-millisecond |
One Redis shard handles ~100k ops/sec comfortably. So a single game needs 2โ3 shards at peak, and 1,000 quiet games share shards by hashing game id. Memory for 1,000 games ร 100k players avg โ 6.4 GB. This is a small system that looks big โ the interview trap is over-architecting it.
You could recompute top-100 every second and serve from cache โ and many games do exactly this for the displayed board. But "what's my rank?" for player #483,201 still needs the full ordering, and rank lookups are the long tail of reads. The sorted set answers both from the same structure, so keep it as source of truth and cache only the rendered top-N page.
The centerpiece: shard by game, sorted set per shard, thin API in front.
flowchart TD
A[Game clients] --> B[API Gateway
auth, rate limit]
B --> C[Leaderboard service
stateless]
C --> D{Shard router
hash game_id}
D --> E[Shard 1
game:42 โ ZSET]
D --> F[Shard 2
game:43 โ ZSET]
D --> G[Shard N
game:44 โ ZSET]
E --> H[(Redis replica
read scaling)]
F --> H
C --> I[(Season archive
S3 - past tournaments)]
J[Score events
Kafka] --> C
Think of it like a tournament bracket board per game: each game gets its own whiteboard (shard) with names already in order. Updating a score is erasing one name and rewriting it in the right slot โ you never re-sort the whole board. The router just makes sure everyone playing game 42 walks to the same whiteboard.
sequenceDiagram
participant C as Game client
participant S as Leaderboard service
participant R as Redis shard
C->>S: POST /games/42/scores {player, score:9500}
S->>R: ZADD gt game:42 9500 player:7
Note over R: GT flag: only update if greater.
O(log n), atomic.
R->>R: ZREVRANK game:42 player:7
R-->>S: rank 1,203
S-->>C: 200 {rank: 1203, top: [...]}
The GT flag matters: "keep my best score" becomes atomic โ no read-modify-write race between two concurrent submissions from the same player.
sequenceDiagram
participant C as Client
participant S as Leaderboard service
participant R as Redis shard
C->>S: GET /games/42/rank?player=7
S->>R: ZREVRANK game:42 player:7
R-->>S: 1202 (0-indexed)
S->>R: ZREVRANGE game:42 1192 1212 WITHSCORES
R-->>S: 21 entries around the player
S-->>C: {rank: 1203, neighbors: [...]}
Two O(log n) operations. The client renders "you are #1,203, here are the 10 above and below you" โ which is the view players actually care about, not the global top 100.
POST /v1/games/{gameId}/scores
{ "playerId": "p7", "score": 9500 } โ { "rank": 1203, "isBest": true }
GET /v1/games/{gameId}/top?n=100 โ [{playerId, score, rank}...]
GET /v1/games/{gameId}/rank/{playerId} โ { rank, score, neighbors:[...] }
POST /v1/games/{gameId}/seasons โ snapshot + reset (weekly)
lb:{gameId}:{season} โ Redis sorted set, member = playerId, score = best score. Ties: member suffix or score micro-offset keeps ordering deterministic.hash(gameId) โ shard โ a game never spans shards, so no cross-shard queries exist.ZRANGE the final board to S3 as JSON/Parquet; serve "last season's winners" from there.| Decision | Option A | Option B | Pick |
|---|---|---|---|
| Shard key | By player | By game | B โ every query is "within a game"; sharding by player would scatter one leaderboard across shards |
| Freshness | Recompute top-100 every 1s, cache it | Read live from the ZSET | Both: live ZSET is source of truth; cache rendered top-100 pages for 1s to absorb read spikes |
| Ties | Arbitrary order | Earlier achiever ranks higher | B โ encode as score + tiny time-based epsilon; players accept "first to get it wins" |
| Cheat scores | Trust the client | Server-side validation | Validate: max plausible score per game + anomaly flagging; leaderboards are cheat magnets |
Redis Cluster (or Upstash/ElastiCache Serverless if I want zero ops) as the entire stateful layer โ sorted sets are the product. Stateless Go or Node API behind a gateway, sharded by hash(gameId) with a static shard map in config. Score events also published to Kafka for anti-cheat analytics offline. Season snapshots to S3. Client SDK batches submissions and polls rank at most every 5s. Total custom code: a few hundred lines. The interview answer is "Redis sorted sets + shard by game" โ everything else is operational detail.
GT flag / best-score atomicity โ it's a small detail that signals real Redis knowledge.ZCOUNT gives "how many players are below X" in O(log n) โ so "top 1%" badges are one extra lookup: rank / ZCARD. No new infrastructure, just arithmetic on values you already have.
Eight players are grinding right now โ scores tick up in real time and the board re-sorts itself, exactly like a sorted set would. Add points to anyone and watch their rank jump.
๐ก Sharding note: game 42's entire board lives on shard 3. Game 43 lives on shard 1 โ no query ever crosses shards, so no global lock exists. That's why this scales: each shard is an independent race.