System design interview ยท backend

Design a real-time leaderboard

"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."

โšก The takeaway first

A leaderboard is a sorted set, not a SQL table. Keep one ordered structure per game where every score update re-sorts in O(log n), read the top 100 straight off the top, and answer "what's my rank?" with a single rank lookup. Then shard by game โ€” each game's leaderboard lives on its own shard, so nothing global ever needs a lock.

๐Ÿ“‹ Requirements

Functional

Non-functional

๐Ÿšซ Common misconception

"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.

๐Ÿงฎ Back-of-the-envelope math

AssumptionValue
Players per popular game1,000,000
Bytes per entry (player id + score)~64 bytes
Memory per leaderboard1M ร— 64 B โ‰ˆ 64 MB โ€” fits in RAM easily
Write rate (peak)20k ZADD/sec per game
Read rate200k reads/sec across games (top-N + rank)
ZADD cost at 1M membersO(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.

Go deeper: why not just cache the top 100?

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.

๐Ÿ—๏ธ Architecture

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.

๐Ÿ” Component deep-dives

Score submit โ€” best-score-wins, one round trip

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.

Rank query + "around me" โ€” two lookups, no scans

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.

๐Ÿ”Œ API + data model

API

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)

Data model

โš–๏ธ Trade-offs

DecisionOption AOption BPick
Shard keyBy playerBy gameB โ€” every query is "within a game"; sharding by player would scatter one leaderboard across shards
FreshnessRecompute top-100 every 1s, cache itRead live from the ZSETBoth: live ZSET is source of truth; cache rendered top-100 pages for 1s to absorb read spikes
TiesArbitrary orderEarlier achiever ranks higherB โ€” encode as score + tiny time-based epsilon; players accept "first to get it wins"
Cheat scoresTrust the clientServer-side validationValidate: max plausible score per game + anomaly flagging; leaderboards are cheat magnets

๐Ÿ”ฅ Failure modes

๐Ÿ› ๏ธ What I'd actually build

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.

๐ŸŽค Interview tips

Go deeper: percentile ranks and "top 1%"

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.

๐ŸŽฎ Interactive widget: live score race

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.

rank query: ZREVRANK ยท O(log n) ยท 0.18 ms shard: game:42 โ†’ shard 3

๐Ÿ’ก 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.

v2026.10.03-01