- A Redis sorted set (ZSET) gives O(log N) score updates, rank lookups and top-N reads. For one leaderboard up to tens of millions of entries, a single primary with replicas is enough.
- Decide the tie rule up front: shared ranks (count strictly higher scores + 1) or earlier-wins (pack an inverted timestamp into the score's low bits).
- Beyond one node, shard by score range (cheap global rank, uneven load) or by user hash (even load, scatter-gather for rank and top-N). Show percentiles for the long tail.
- Fantasy sports is a different beast: one real-world event changes the score of millions of teams at once. Recompute team totals from player points in batch and re-rank per contest on a short cadence, instead of millions of ZINCRBYs per ball.
- Redis is a derived view. Keep scores in a durable store, rebuild on failure, and compute prize-deciding results from the source of truth.
"Design a leaderboard" looks like a five-minute question: put scores in a Redis sorted set, done. Interviewers know that, so they push in one of two directions. Either the board gets huge (a global game with 200 million players, which no single node holds comfortably), or the update pattern gets weird (a fantasy cricket contest where one boundary changes the score of every team that picked that batter). This guide answers the easy version properly and then both hard versions.
Two Kinds of Leaderboard
| Aspect | Gaming leaderboard | Fantasy contest leaderboard (Dream11-style) |
|---|---|---|
| Score changes when | A player finishes a game/level | Any real player in the match does something (run, wicket, catch) |
| Writes per event | 1 user's score | Every team containing that player: potentially millions |
| Write pattern | Steady stream of independent increments | Bursty, correlated, all at once after each ball |
| Boards | Few (global, daily, per-country, friends) | Many: one per contest, from 2-person contests to mega contests with millions of entries |
| Freshness needed | Seconds | Tens of seconds is fine; correctness at the end is critical (prize money) |
Clarify which one the interviewer means in the first two minutes. The rest of this guide covers both.
Requirements
Functional
- Update a user's score (game result, or derived from live match events).
- Get top N (e.g. top 100) with scores.
- Get a given user's rank and score, and the players around them ("you are #48,213; here are #48,208–#48,218").
- Multiple boards: all-time, daily/weekly, per region, per contest, friends.
- For contests: final, auditable ranks used to distribute prizes.
Non-functional
- Rank and top-N reads under ~50 ms; leaderboard pages are opened constantly during a live match.
- Score updates visible within a few seconds (gaming) or within a short refresh cycle (fantasy).
- Durable and correct final standings; no lost score updates.
- Handles the spike of the first ball of an India vs Pakistan match, when a very large number of users open the app together.
Estimates
Gaming
- 50M DAU, 10 games/day each → 500M score updates/day ≈ 5,800 writes/s, peaks ~20K/s.
- Leaderboard views: maybe 3 per session × 5 sessions → 750M reads/day ≈ 8,700/s, peaks ~30K/s.
- Memory: a ZSET entry costs on the order of 100 bytes including skip list and hash overhead (depends on member length). 200M players ≈ 20 GB for the all-time board. Possible on one large node, uncomfortable for failover and snapshotting; a natural point to discuss sharding.
Fantasy (assumed numbers for a big IPL match, not company data)
- 30M teams created for the match; 100M contest entries (a team can join many contests).
- Contests: a few mega contests with up to several million entries each, and a very long tail of small ones (2 to 100 entries).
- Score-changing events: roughly every ball, ~240 balls in a T20 plus fielding events.
- Naive per-team increments: if a popular batter is in 70% of teams, one boundary means 21M ZINCRBYs, and since a team can sit in several contests, more like 70M ZSET updates per ball. Every ~30 seconds. That's a few million writes per second, which is the reason we need a smarter approach.
API
POST /v1/boards/{board_id}/scores # internal: game server only
{ "user_id": "u_91", "delta": 120, "match_id": "m_55", "ts": 1760100000 }
GET /v1/boards/{board_id}/top?limit=100
GET /v1/boards/{board_id}/users/{user_id}?around=5
→ { "rank": 48213, "score": 9120, "neighbours": [ ... ] }
GET /v1/contests/{contest_id}/leaderboard?page_token=...
GET /v1/contests/{contest_id}/teams/{team_id}/rank
Score submission must come from a trusted game server, never directly from the client; a client-reported score invites cheating. Include a match_id so a replayed request is deduplicated.
Redis Sorted Sets: The Core Primitive
A ZSET stores unique members with a floating-point score, kept ordered in a skip list (plus a hash table for member → score lookups). The operations you need:
| Need | Command | Complexity |
|---|---|---|
| Set score | ZADD board 9120 u_91 | O(log N) |
| Add to score | ZINCRBY board 120 u_91 | O(log N) |
| User's rank (0-based, high to low) | ZREVRANK board u_91 | O(log N) |
| User's score | ZSCORE board u_91 | O(1) |
| Top 100 | ZREVRANGE board 0 99 WITHSCORES (or ZRANGE ... REV) | O(log N + 100) |
| Players around rank r | ZREVRANGE board r-5 r+5 WITHSCORES | O(log N + 11) |
| How many have a strictly higher score | ZCOUNT board (9120 +inf | O(log N) |
| Board size | ZCARD board | O(1) |
Why a skip list and not a balanced tree or a sorted array? Each skip-list node tracks the span of its forward pointers, so Redis can compute a rank by summing spans along the search path in O(log N). A sorted array gives O(1) rank but O(N) inserts; a SQL ORDER BY score with COUNT(*) WHERE score > x is O(N) per rank query without special index support. More on the internals in the Redis deep dive.
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
BOARD = "lb:global"
def add_points(user_id: str, delta: int) -> float:
return r.zincrby(BOARD, delta, user_id)
def top(n=10):
return r.zrevrange(BOARD, 0, n - 1, withscores=True)
def rank_and_neighbours(user_id: str, k=5):
rank = r.zrevrank(BOARD, user_id) # 0-based, None if absent
if rank is None:
return None
lo = max(0, rank - k)
window = r.zrevrange(BOARD, lo, rank + k, withscores=True)
return {"rank": rank + 1,
"score": r.zscore(BOARD, user_id),
"neighbours": [(lo + i + 1, m, s) for i, (m, s) in enumerate(window)]}
def competition_rank(user_id: str):
"""Shared rank for ties: 1 + number of strictly higher scores."""
s = r.zscore(BOARD, user_id)
return None if s is None else r.zcount(BOARD, f"({s}", "+inf") + 1
Tie-Breaking: Pick a Rule, Then Encode It
By default, Redis orders equal scores by member name (lexicographically), so ZREVRANK gives tied users different ranks based on their IDs. That's arbitrary, which players notice and complain about. Choose an explicit rule:
Rule A: tied players share a rank (standard competition ranking, "1, 2, 2, 4")
Use ZCOUNT of strictly higher scores + 1, as in competition_rank above. Prize distribution for ties is then a business rule; fantasy platforms commonly split the combined prize for tied ranks among the tied users (check each platform's published rules).
Rule B: whoever reached the score first ranks higher
Pack time into the score. A Redis score is an IEEE-754 double, which represents integers exactly up to 253. Split the 53 bits:
- High bits: points (×10 if you need one decimal place). 24 bits allows up to ~16.7M.
- Low 28 bits:
2^28 − 1 − seconds_since_season_start. 228 seconds is about 8.5 years. Earlier timestamps produce larger low bits, so they rank higher among equal points.
TS_BITS = 28
TS_MAX = (1 << TS_BITS) - 1
def composite(points: int, secs_since_start: int) -> int:
assert 0 <= secs_since_start <= TS_MAX and points < (1 << 24)
return points * (1 << TS_BITS) + (TS_MAX - secs_since_start)
def decode(score: float):
s = int(score)
return s >> TS_BITS, TS_MAX - (s & TS_MAX) # (points, secs)
# Points live in a hash; the ZSET holds the composite. Lua keeps both in sync atomically.
ADD_POINTS = r.register_script("""
local pts = redis.call('HINCRBY', KEYS[2], ARGV[1], ARGV[2])
local score = pts * 268435456 + (268435455 - tonumber(ARGV[3]))
redis.call('ZADD', KEYS[1], score, ARGV[1])
return pts
""")
def add_points_earliest_wins(user_id, delta, secs_since_start):
return ADD_POINTS(keys=["lb:global", "lb:global:pts"],
args=[user_id, delta, secs_since_start])
Why not just ZINCRBY the composite? Incrementing would add to the timestamp bits too; the tiebreaker must reflect the time of the latest change, so you recompute it, and a Lua script makes the read-modify-write atomic.
Gaming Leaderboard Architecture
- Game server reports the result to the Score Service with a unique
match_id. - Score Service dedupes (unique constraint on
(user_id, match_id)), writes the score event durably, then updates Redis boards (global, daily, weekly, regional) in one pipeline or Lua script. - Leaderboard Service serves reads from Redis replicas, enriching IDs with names/avatars from a profile cache with a single
MGET. - Top-100 cache: the top of the board is read by everyone. Cache the rendered top-100 for 1–5 seconds in the service or CDN; that removes most of the read load from Redis.
If writes are very hot, put Kafka between the Score Service and the Redis updater, partitioned by user, and let consumers batch updates in pipelines. You trade a second or two of freshness for much higher throughput.
Sharding Leaderboards That Don't Fit One Node
When one ZSET grows past what a single Redis node handles comfortably (memory, snapshot time, failover time, single-threaded CPU), you have three options:
Option 1: shard by score range
Shard 0 holds scores 0–999, shard 1 holds 1,000–4,999, shard 2 holds 5,000+, etc. A user's global rank = (sum of ZCARD of all higher-score shards) + local rank. Top-N reads only touch the highest shard. Cons: score distributions are skewed (most players have low scores), so ranges must be tuned and rebalanced; a user whose score crosses a boundary moves between shards (remove + add, needs care for atomicity).
Option 2: shard by user hash
Each of S shards holds a random 1/S of users. Even load, no movement. Cost:
- Top N: fetch top N from every shard and merge (a k-way merge with a heap): S × N items, cheap for N = 100.
- User rank: sum over shards of
ZCOUNT (score +inf→ S round trips in parallel. With S = 16, fine. - Neighbours around a mid-table user: hard. Use score-based windows: ask each shard for k members just above and below the user's score, then merge.
Option 3: exact at the top, approximate below
Nobody needs to know they are exactly #87,341,129. Keep an exact ZSET for the top 100K, and for everyone else maintain a score histogram (count of players per score bucket). Rank ≈ (players in higher buckets) + (position within bucket estimated by interpolation). Display "Top 23%". This turns a 200M-member ZSET into a 100K ZSET plus a few thousand counters, and is how many large games present global ranks.
| Approach | Top-N | User rank | Write balance | Complexity |
|---|---|---|---|---|
| Single ZSET | O(log N) | O(log N) | One node | Lowest |
| Score-range shards | One shard | Local + sizes | Skewed | Rebalancing |
| Hash shards | Scatter-gather merge | Scatter-gather count | Even | Medium |
| Top-K exact + histogram | One small ZSET | Approximate | Even | Medium, best UX at scale |
General sharding trade-offs (hot keys, resharding) are covered in database sharding strategies.
Dream11-Scale Contests: When One Ball Changes Millions of Scores
Here is the twist that makes fantasy sports interesting. A user's team score isn't an independent counter; it's a function of 11 real players' points (with the captain doubled and vice-captain at 1.5x). When a batter hits a four, every team containing that batter changes. The scalable insight is to stop thinking "update team scores" and start thinking "recompute a derived view".
Pipeline
- Match feed → player points. A scoring service consumes the ball-by-ball feed and maintains points per real player (22 numbers, plus playing-XI bonuses). This is tiny.
- Snapshot. Every T seconds (say 15–60 s, or after each ball), publish a versioned player-points vector:
{version: 183, points10: [1180, 450, ...]}. - Team scoring. Team definitions (30M teams × 11 player indices + C/VC) are loaded into memory across a pool of workers, partitioned by team ID. Each worker recomputes all its teams' scores for the new version. 30M × 11 additions is ~330M simple operations, a second or two across a modest cluster; it's embarrassingly parallel.
- Contest ranking. For each contest, rank its entries by team score. Small contests (the vast majority) are ranked in memory instantly. Mega contests with millions of entries are sorted or bucket-ranked by dedicated workers; sorting 5M integers takes well under a second on one core.
- Publish. Write each contest's ranked view (versioned) to a read store: top pages to Redis/cache, a user's rank via a lookup table
(contest_id, team_id) → rank. Readers always see a consistent snapshot version, never a half-updated board.
#include <algorithm>
#include <cstdint>
#include <iostream>
#include <vector>
struct Team { int64_t teamId; int16_t players[11]; int16_t captain, viceCaptain; };
struct Entry { int64_t teamId; int32_t points10; int32_t rank; }; // points x10
// Recompute every team's score from the latest player points (x10 avoids floats).
std::vector<int32_t> scoreTeams(const std::vector<Team>& teams,
const std::vector<int32_t>& playerPts10) {
std::vector<int32_t> out(teams.size());
for (size_t i = 0; i < teams.size(); ++i) {
const Team& t = teams[i];
int64_t s = 0;
for (int p : t.players) {
int32_t base = playerPts10[p];
if (p == t.captain) s += base * 2; // 2x
else if (p == t.viceCaptain) s += base * 3 / 2; // 1.5x
else s += base;
}
out[i] = static_cast<int32_t>(s);
}
return out;
}
// Equal scores share a rank and the next rank skips (1, 1, 3, 4).
void rankContest(std::vector<Entry>& entries) {
std::sort(entries.begin(), entries.end(), [](const Entry& a, const Entry& b) {
return a.points10 > b.points10;
});
for (size_t i = 0; i < entries.size(); ++i) {
if (i > 0 && entries[i].points10 == entries[i - 1].points10)
entries[i].rank = entries[i - 1].rank;
else
entries[i].rank = static_cast<int32_t>(i + 1);
}
}
int main() {
std::vector<int32_t> pts10(22, 0);
pts10[0] = 1180; pts10[1] = 450; pts10[12] = 760; // 118, 45, 76 points
std::vector<Team> teams = {
{101, {0,1,2,3,4,5,12,13,14,15,16}, 0, 12}, // C = player 0
{102, {0,1,2,3,4,5,12,13,14,15,16}, 12, 0}, // C = player 12
{103, {1,2,3,4,5,6,12,13,14,15,16}, 12, 1},
{104, {0,1,2,3,4,5,12,13,14,15,16}, 0, 12}, // same picks as 101
};
std::vector<int32_t> scores = scoreTeams(teams, pts10);
std::vector<Entry> contest;
for (size_t i = 0; i < teams.size(); ++i) contest.push_back({teams[i].teamId, scores[i], 0});
rankContest(contest);
for (const Entry& e : contest)
std::cout << "#" << e.rank << " team " << e.teamId << " " << e.points10 / 10.0 << "\n";
}
// Output:
// #1 team 101 395
// #1 team 104 395
// #3 team 102 374
// #4 team 103 219.5
Dry run: team 101 = 118×2 (captain) + 76×1.5 (vice-captain) + 45 = 236 + 114 + 45 = 395. Team 102 swaps C and VC: 76×2 + 118×1.5 + 45 = 152 + 177 + 45 = 374. Team 103 lacks player 0: 76×2 + 45×1.5 = 152 + 67.5 = 219.5. Teams 101 and 104 tie at 395 and share rank 1; the next rank is 3. Note std::sort isn't stable, so 101 and 104 could print in either order; ties don't need a deterministic order when they share a rank, but if you paginate, add teamId as a secondary key so pages are stable.
Why not ZSETs per contest with live increments?
- Write amplification: one event fans out to every team with that player, times every contest that team joined.
- Consistency: midway through millions of increments, the board shows a mix of old and new scores. Batch recomputation with versioned snapshots is cleaner.
- You can still use ZSETs as the serving structure for mega contests (write the new snapshot into a fresh key, then atomically swap with
RENAME), getting rank queries for free.
Optimisations worth mentioning
- Duplicate teams: many users pick identical XIs with the same C/VC. Hash the team composition and score each distinct composition once.
- Delta scoring: only teams containing players whose points changed need recomputing. Maintain an inverted index player → teams if you go this way; with only 22 players it often doesn't beat full recomputation by much.
- Read spikes: at the start of the match and after big moments, a very large number of users open "my contests". Serve from caches with the snapshot version as part of the key; push "leaderboard updated" hints over WebSocket rather than having clients poll aggressively (see WebSockets vs SSE). The traffic profile resembles the one in Hotstar's IPL streaming, and the same pre-scaling playbook applies.
- Freeze at deadline: team edits are locked at the match start, so team definitions are immutable during the match, which is what makes in-memory recomputation simple.
Persistence and Recovery
Redis can persist (RDB snapshots, AOF logs) and replicate, but replication is asynchronous, so a failover can lose the last few writes. For leaderboards that matters in two ways:
- Source of truth elsewhere. Score events (gaming) or team definitions + player-points versions (fantasy) are stored durably: SQL, Cassandra or a Kafka log with long retention. Any leaderboard can be rebuilt by replaying.
- Rebuild path tested regularly. Rebuilding a 20M-entry ZSET by pipelining ZADDs in batches of a few thousand takes on the order of a minute or two on a single node. Know your number.
- Final results are computed offline. When the match ends and points are finalised (including any post-match corrections), a batch job computes final ranks from the durable store, writes them to SQL, and only then are winnings credited. The live board is for display.
- Idempotent updates. Gaming: dedupe on
match_id. Fantasy: snapshots are versioned, and applying version 183 twice is harmless.
Daily, Weekly and Friends Leaderboards
- Time windows: one key per window (
lb:daily:2026-10-10,lb:weekly:2026-W41). Write to all relevant keys on each update; setEXPIREso old windows vanish. A rolling "last 7 days" board can be computed withZUNIONSTOREover 7 daily keys, cached for a few minutes. - Regional: separate keys per country/state; a user is in one region board plus global.
- Friends: with up to a few hundred friends, don't maintain a ZSET per user. Fetch friends' scores with one
ZMSCOREcall on the global board and sort in the app server. O(F log F) with F small. - Season resets: create the new season's key and switch a pointer; archive the old key's final standings to the database.
Trade-offs to Discuss
| Decision | Option A | Option B |
|---|---|---|
| Ranking store | Redis ZSET: fast, in memory, rebuildable | SQL with index on score: durable, but rank = COUNT over an index range, slower at scale |
| Update model | Per-event increments: fresh, high write amplification for derived scores | Periodic snapshots: consistent, slightly stale |
| Exactness | Exact rank for everyone: expensive at 100M+ | Exact top-K + percentile for the rest: cheap, good UX |
| Tie rule | Shared rank: fair, prize splitting | Earlier-wins: unique ranks, needs composite scores |
| Freshness | Push updates: lower latency, connection cost | Client polls with cached pages: simple, slightly stale |
Interview Checklist
ZSET operations and complexity
ZADD/ZINCRBY, ZREVRANK, ZREVRANGE, ZCOUNT; why a skip list makes rank O(log N).
Tie rule
Ask the interviewer; implement shared rank via ZCOUNT or earlier-wins via composite score.
Scale beyond one node
Range vs hash sharding, scatter-gather, approximate percentiles.
Derived scores
For fantasy: recompute from player points in batch, versioned snapshots, final results from the source of truth.
Common mistakes
- "SELECT ... ORDER BY score LIMIT 100" on every request and
COUNT(*)for ranks on a 100M-row table. - Letting clients submit their own scores.
- Ignoring ties, or relying on Redis's lexicographic member order without saying so.
- Treating Redis as the only copy of prize-deciding data.
- For fantasy contests, proposing millions of ZINCRBYs per ball without noticing the write amplification.
Related problems use the same toolbox: top-K trending hashtags swaps exact sorted sets for streaming approximations, and a distributed cache design explains the replication and failover behaviour behind the Redis layer.
Frequently Asked Questions
Ace System Design Rounds at Product Companies
Prepflix's system design course covers ranking, caching and real-time problems like leaderboards with guided mock interviews.
Explore the Prepflix Course →