Design a real-time leaderboard
The interviewer asks
“Design a real-time leaderboard for a game or fantasy-sports contest. Users want to see the top 100 and their own rank, updated within seconds as scores change, for contests with up to tens of millions of participants.”
What a strong answer looks like
A strong answer starts with Redis sorted sets because ZADD, ZREVRANGE and ZREVRANK are O(log N) and fit the problem exactly, then shows where that breaks. One sorted set per leaderboard works up to tens of millions of members on one node; beyond that, or with very high update rates, you shard by score range or use approximate ranks from a score histogram for users far from the top. A durable source of truth (scores in a database or an event log in Kafka) lets you rebuild Redis. The fantasy-sports twist is important: one real-world event (a wicket) changes the score of millions of teams at once, so updates are batch recomputations, not single increments. Tie-breaking, reads at high QPS and freezing final results for prize payouts round out the answer.
Concepts to mention
1 Requirements
Functional
- Update a user's score when they earn points (game event or match event)
- Show the top N (e.g. top 100) of a leaderboard
- Show a user's own rank and the users just above and below them
- Many leaderboards: per contest, per region, daily/weekly/all-time
- Freeze and publish final standings at the end of a contest for rewards
Non-functional
- Largest contest: 20M participants; 10M+ concurrent users checking ranks during a live match
- Rank and top-N reads p99 under 50 ms
- Score changes visible on the leaderboard within ~5 s
- Final standings must be exact, deterministic and auditable (money depends on it)
- 99.95% availability during live events
Out of scope (say it out loud)
- Scoring rules of the game itself
- Payments and prize distribution (only the final ranking hand-off)
- Anti-cheat detection beyond basic validation
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/leaderboards/{board_id}/scores | Internal: set or increment a user's score (idempotent by event_id) |
GET/v1/leaderboards/{board_id}/top?limit=100 | Top N entries with user names and scores |
GET/v1/leaderboards/{board_id}/me?around=5 | My rank, score, and the 5 entries above and below |
GET/v1/leaderboards/{board_id}/friends | Ranks of my friends only |
POST/v1/leaderboards/{board_id}/freeze | Internal: lock the board and produce final standings for payouts |
4 Data model
leaderboard sorted setRedis (cluster)key lb:{board_id}member = user_id (or team_id)score = composite scoreZADD, ZINCRBY, ZREVRANGE, ZREVRANK all O(log N). Hash tag {board_id} keeps one board on one slot unless sharded explicitly.
scores (source of truth)Postgres / DynamoDBboard_iduser_id (PK together)scorelast_event_idupdated_atDurable and used to rebuild Redis and to compute final standings.
score eventsKafkaevent_idboard_iduser_iddelta or new scoretsPartitioned by board_id (or board_id + user hash for big boards); replayable.
user profile cacheRedis hash / local cacheuser_id → display nameavatarLeaderboard returns IDs; names are joined from this cache in one MGET.
final standingsPostgres + S3 exportboard_idrankuser_idscoretie_breakfrozen_atImmutable once written; used by payouts and audit.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Score update in a game
- 1Game service validates the action server-side (never trust client scores) and emits a score event with an event_id to Kafka
- 2Updater consumes the partition, upserts the score in the DB with last_event_id check for idempotency
- 3Updater runs ZADD lb:{board} <composite score> <user_id>, batching many updates per pipeline
- 4Within a second or two the new rank is visible to readers
Fantasy contest: a wicket falls
- 1Match data provider sends the ball-by-ball event; the points service computes new fantasy points for affected players
- 2Recompute job knows which teams contain those players (inverted index player_id → team_ids, built when contest locks)
- 3For each contest, it recomputes team totals in parallel workers (each worker owns a range of team IDs) and writes ZADD batches of ~1,000 entries
- 4Updates for one board go through one writer pipeline per shard to keep Redis CPU busy but not overloaded
- 5Read API keeps serving; users may briefly see a mix of old and new scores for a few seconds, which is acceptable during a live match
Read my rank
- 1Client calls /me; read API runs ZREVRANK and ZSCORE, then ZREVRANGE rank-5 to rank+5
- 2It fetches display names for those IDs with one MGET from the profile cache
- 3Top-100 is read from a small cache refreshed every 1 s, since every user asks for the same list
7 Deep dives
Why sorted sets, and their limits
A Redis sorted set is a skiplist plus a hash map. Insert or update is O(log N), rank lookup is O(log N), and a range of the top K is O(log N + K). For 20M members, log N is about 25 steps, so each operation takes microseconds and one node handles over 100k ops/s. Memory is around 100 bytes per member, so 20M members is about 2 GB, comfortably on one node. A SQL alternative, SELECT COUNT(*) WHERE score > my_score, needs an index scan proportional to your rank, which is slow for users ranked millions deep and terrible at high read QPS. Limits appear in three places: a single board's write rate is capped by one Redis core (~100k-200k ops/s pipelined); very large boards (hundreds of millions, e.g. an all-time global game ranking) exceed comfortable memory on one node; and because Redis is in memory, you need a durable source of truth and a rebuild path. Say all three, then pick fixes only when the numbers demand them. For most contests one sorted set per board, with a replica for reads, is the right answer.
Sharding a huge leaderboard
If one board outgrows a node, shard it. Sharding by user_id hash spreads load evenly but makes rank hard: your global rank is the sum of 'how many users beat me' across all shards, so a rank query fans out to every shard (ZCOUNT score +inf on each). With 10-20 shards that is acceptable for occasional reads, and top-N becomes a merge of each shard's top N. Sharding by score range instead puts users with scores 0-1,000 on shard A, 1,000-5,000 on B, and so on. Rank is then local rank plus the total size of all higher shards (cached counts), so a single query is enough. The downside is hot and uneven shards and users moving between shards when scores change, which needs a delete-then-add across nodes. A third option is approximate rank: keep exact sorted sets only for the top ~100k, and for everyone else maintain a histogram of scores in buckets; a user's rank is the count of users in higher buckets plus an estimate within their bucket. Users ranked 4,512,330 do not care whether it says 'about 4.5M'. Many games use exactly this.
Ties and exact final standings
Ties matter when money is involved. Redis orders equal scores lexicographically by member, which is arbitrary from the user's point of view. Define the tie-break rule explicitly with product: for example, the earlier team to reach that score wins, or the team created earlier wins, or ties share the rank and split the prize. If you need a tie-break in the sorted set, encode it into the score: composite = points × 10^k + (MAX_TS - timestamp), which still fits in a double's 53-bit integer precision if you size k carefully (fantasy points with one decimal and a seconds-level timestamp fit). For final results, do not trust the live Redis board. At the end of the contest, freeze writes, then compute final standings from the durable scores table with a deterministic SQL query (ORDER BY points DESC, tie_break ASC), write them as an immutable table and export to S3. Compare with the Redis view as a sanity check. Payouts read only the frozen standings, and every number is traceable to score events.
Read traffic and caching
During a live match, millions of users refresh constantly. The top-100 list is identical for everyone in that contest, so cache it in the read service memory or a CDN-friendly endpoint with a 1-2 s TTL; it then costs one Redis call per second per server instead of tens of thousands. 'My rank' differs per user, but it is just one or two O(log N) calls; serve these from Redis read replicas so the primary keeps CPU for writes. Push rather than pull is also possible: send rank updates over WebSocket only to users whose rank changed significantly, but for fantasy contests polling every 30 s with caching is simpler and good enough. Friends leaderboards are small: fetch the friend list (hundreds), then ZSCORE them with a pipeline and sort in the service, instead of maintaining a sorted set per user.
8 Trade-offs
Redis sorted set vs database ranking queries
A database is durable and supports complex queries, but rank queries scale with rank depth and do not survive 200k reads/s. Redis is fast and fits the access pattern. Use both: DB for truth, Redis as the serving index.
Exact vs approximate rank
Exact ranks for everyone need one big set or a fan-out query. Approximate ranks via histograms are cheap and scale indefinitely. Use exact for the top segment where users compare closely and money depends on it; approximate for the long tail.
Incremental updates vs batch recompute
ZINCRBY per event is simple for games. For fantasy sports, one match event touches millions of teams, so batch recompute with parallel workers and pipelined ZADDs is the right model.
Strong consistency during live play vs freshness
We accept a few seconds where some teams show the new score and others the old one. For the final result we freeze and recompute from truth, so correctness where it matters is guaranteed.
9 Bottlenecks
Single board write throughput
One Redis core limits one sorted set. Pipeline aggressively, shard by score range for extreme boards, or split huge contests into multiple sub-leaderboards that the product already shows separately.
Many boards for one event
A big match can have hundreds of thousands of contests, each with its own board. Spread board keys across the Redis cluster by hash slot; recompute contests in parallel; prioritise the largest and most viewed contests.
10 Failure handling
Redis node lost
A replica is promoted (Redis Sentinel or Cluster). Any updates not yet replicated are recovered by replaying recent Kafka events, since updates are idempotent ZADDs of absolute scores.
Updater falls behind
Consumer lag grows and the leaderboard is stale. Autoscale updaters up to partition count; show 'last updated at' in the UI so users know. Final standings are unaffected because they come from the DB.
Full rebuild
Stream the scores table into a new sorted set key with pipelined ZADDs (20M in a couple of minutes), then atomically switch an alias key or config pointer to it.
11 What impresses
- Doing the memory math: 20M × ~100 B ≈ 2 GB fits one node
- Explaining why COUNT(*) WHERE score > x does not scale
- Spotting the fantasy-sports burst where one event changes millions of scores
- Approximate rank for the long tail
- Defining tie-break rules and making final standings deterministic and auditable
- Serving top-N from a 1 s cache
12 Mistakes
- Computing rank with a SQL count query on every read
- Using Redis as the only copy of scores
- Trusting client-sent scores
- Ignoring ties when prizes depend on rank
- Sharding by user hash without explaining how global rank is computed
- Sharding prematurely before checking that one node is not enough
13 Follow-ups
Lessons this design uses
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.