System Design · Real-Time Ranking

Design a Real-Time Leaderboard (Dream11 / Gaming)

From a single Redis sorted set for a mobile game to recomputing tens of millions of fantasy teams every time Kohli hits a four: ranks, ties, sharding and durability done right.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 17 min read
Glowing leaderboard bars rising like a skyline with cricket and gaming elements, representing a real-time leaderboard
TL;DR
  • 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

AspectGaming leaderboardFantasy contest leaderboard (Dream11-style)
Score changes whenA player finishes a game/levelAny real player in the match does something (run, wicket, catch)
Writes per event1 user's scoreEvery team containing that player: potentially millions
Write patternSteady stream of independent incrementsBursty, correlated, all at once after each ball
BoardsFew (global, daily, per-country, friends)Many: one per contest, from 2-person contests to mega contests with millions of entries
Freshness neededSecondsTens 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

REST 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:

NeedCommandComplexity
Set scoreZADD board 9120 u_91O(log N)
Add to scoreZINCRBY board 120 u_91O(log N)
User's rank (0-based, high to low)ZREVRANK board u_91O(log N)
User's scoreZSCORE board u_91O(1)
Top 100ZREVRANGE board 0 99 WITHSCORES (or ZRANGE ... REV)O(log N + 100)
Players around rank rZREVRANGE board r-5 r+5 WITHSCORESO(log N + 11)
How many have a strictly higher scoreZCOUNT board (9120 +infO(log N)
Board sizeZCARD boardO(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.

Python (redis-py): basic leaderboard
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.
Python: composite score with an atomic Lua update
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.

⚠️
Don't use floats for fractional pointsFantasy points like 0.5 for a dot ball or 1.5x vice-captain multipliers should be stored as integers ×10 (or ×100). Floating-point sums of many 0.1-style values drift, and two teams that should tie may differ in the 15th decimal place and get different ranks.

Gaming Leaderboard Architecture

Game clients | play v +-----------------+ match result +--------------------+ | Game servers |-------------------->| Score Service | | (authoritative) | | validate, dedupe | +-----------------+ +----+-----------+---+ | | durable write | | ZINCRBY / Lua v v +---------------+ +-------------------+ | Scores DB | | Redis cluster | | (SQL / Kafka | | lb:global | | + Cassandra) | | lb:daily:20261010 | +-------+-------+ | lb:IN:weekly:41 | | +---------+---------+ rebuild | ^ +--------------------+ Clients --> API --> Leaderboard Service --> reads Redis (replicas) + profile cache (names, avatars)
  1. Game server reports the result to the Score Service with a unique match_id.
  2. 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.
  3. Leaderboard Service serves reads from Redis replicas, enriching IDs with names/avatars from a profile cache with a single MGET.
  4. 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.

ApproachTop-NUser rankWrite balanceComplexity
Single ZSETO(log N)O(log N)One nodeLowest
Score-range shardsOne shardLocal + sizesSkewedRebalancing
Hash shardsScatter-gather mergeScatter-gather countEvenMedium
Top-K exact + histogramOne small ZSETApproximateEvenMedium, 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

  1. 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.
  2. Snapshot. Every T seconds (say 15–60 s, or after each ball), publish a versioned player-points vector: {version: 183, points10: [1180, 450, ...]}.
  3. 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.
  4. 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.
  5. 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.
C++: recompute team scores and rank a contest (standard competition ranking)
#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; set EXPIRE so old windows vanish. A rolling "last 7 days" board can be computed with ZUNIONSTORE over 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 ZMSCORE call 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

DecisionOption AOption B
Ranking storeRedis ZSET: fast, in memory, rebuildableSQL with index on score: durable, but rank = COUNT over an index range, slower at scale
Update modelPer-event increments: fresh, high write amplification for derived scoresPeriodic snapshots: consistent, slightly stale
ExactnessExact rank for everyone: expensive at 100M+Exact top-K + percentile for the rest: cheap, good UX
Tie ruleShared rank: fair, prize splittingEarlier-wins: unique ranks, needs composite scores
FreshnessPush updates: lower latency, connection costClient polls with cached pages: simple, slightly stale

Interview Checklist

Must say

ZSET operations and complexity

ZADD/ZINCRBY, ZREVRANK, ZREVRANGE, ZCOUNT; why a skip list makes rank O(log N).

Must say

Tie rule

Ask the interviewer; implement shared rank via ZCOUNT or earlier-wins via composite score.

Strong signal

Scale beyond one node

Range vs hash sharding, scatter-gather, approximate percentiles.

Strong signal

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

Why are Redis sorted sets used for leaderboards?▾
A Redis sorted set keeps members ordered by score using a skip list plus a hash table, so updating a score, fetching a member's rank and reading the top N are all logarithmic in the number of members. That gives millisecond rank queries on millions of entries without writing any ranking code.
How do you handle ties in a leaderboard?▾
First decide the business rule: tied players share a rank, or the earlier achiever ranks higher. For shared ranks, compute rank as the number of players with a strictly higher score plus one. For earlier-wins, encode a time component into the score, such as points shifted left by a number of bits plus the inverse of the time the score was reached.
How do you scale a leaderboard beyond one Redis node?▾
Either shard by score range so each shard holds a contiguous band of scores and a global rank is the size of higher shards plus the local rank, or shard by user hash and merge top-N lists from every shard, computing a user's rank by summing counts of higher scores across shards. Very large boards often also show approximate percentile ranks.
How does Dream11 update leaderboards during a match?▾
Dream11's internal implementation is not fully public. A design that fits the problem computes fantasy points per real player from the live match feed, recomputes each user team's total from those player points in a distributed batch, and re-ranks each contest's entries at short intervals, rather than issuing millions of individual increments per ball.
What happens to the leaderboard if Redis crashes?▾
Treat Redis as a derived view, not the source of truth. Store score events or final scores durably in a database or log, enable Redis persistence and replicas for fast recovery, and be able to rebuild any leaderboard from the durable store. Final results for prizes should always be computed from the durable store.
How do you get a user's neighbours on the leaderboard?▾
Get the user's zero-based rank with ZREVRANK, then read a window around it with ZREVRANGE from rank minus k to rank plus k with scores. Both calls are logarithmic, so the 'you and the five players around you' view is cheap even for millions of entries.

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 →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.