Real-time leaderboard
Rank millions of players by score in real time: update a player's score when a match ends, fetch the global top 100, and show any player their exact rank and neighbours. SQL `COUNT(*)` rank queries collapse at scale; augmented skip lists (Redis sorted sets) turn both updates and rank lookups into O(log N) memory operations.
Component labels and connections
- Clients (client) · calls: Load balancer
- Load balancer (loadBalancer) · calls: Leaderboard API 1, Leaderboard API 2
- Leaderboard API 1 (service) · calls: Top-100 cache (1s TTL), Sorted-set rank store (Redis), Durable score ledger
- Leaderboard API 2 (service) · calls: Top-100 cache (1s TTL), Sorted-set rank store (Redis), Durable score ledger
- Top-100 cache (1s TTL) (cache) · calls: none
- Sorted-set rank store (Redis) (kvStore) · calls: none
- Durable score ledger (database) · calls: none
Requirements
Functional
- Record a match score (increment or max-score update) for a user on a current tournament or daily/monthly board.
- Fetch the top K players (e.g., top 100) with their scores and ranks.
- Fetch any player's own global rank and the k players immediately above and below them.
Non-functional
- Score updates visible in rankings within ~100 ms; read p99 under ~50 ms.
- Support 25M DAU on a single global board without full-table scans.
- Durable score history so an in-memory node failure can be rebuilt without losing player points.
Worked estimates
Score writes
≈ 2,900 updates/s average, ≈ 9,000/s peak
25M DAU × 10 matches/day ÷ 86,400
Rank & top-K reads
≈ 5,800 reads/s average, ≈ 18,000/s peak
25M DAU × 20 views/day ÷ 86,400
Sorted-set memory
≈ 1.6 GB per board — fits in a single Redis instance's RAM!
25M users × ~64 B (8 B userId + 8 B score + skip-list pointers & hash entry)
Skip-list ops
a single Redis thread serves ~50k–100k ZADD/ZREVRANK ops/s
log₂(25,000,000) ≈ 25 pointer hops in RAM
Tie-breaking
(score << 20) | (MAX_TS - epochSec) breaks ties deterministically in favour of who reached the score first
64-bit IEEE float (53-bit integer mantissa) or composite score
API
POST /v1/leaderboards/{boardId}/scores
Request: { userId, delta, matchId } + Idempotency-Key
Response: 200 { newScore, rank }
matchId deduplicates retried game-server reports.
GET /v1/leaderboards/{boardId}/top?limit=100
Request: limit ≤ 100
Response: 200 { entries[{ rank, userId, score }] }
GET /v1/leaderboards/{boardId}/users/{userId}?neighbours=5
Request: —
Response: 200 { userId, score, rank, window[] }
Data model
- scores — store: scoredb
- rankSet — store: redis
rankSet is an in-memory Redis sorted set (`ZSET`) per board (`leaderboard:2026-10`): it pairs a hash table (`userId → score`) with an augmented skip list whose forward pointers store the span (number of nodes skipped), giving O(log N) rank computation (`ZREVRANK`).
scores is the durable ledger in a relational or wide-column store, used for auditing and rebuilding the sorted set if memory is lost.
Display names and avatars are hydrated from a user profile cache by userId after fetching the top IDs.
High-level design
Trace permitted calls in the architecture above, then open the reference canvas to inspect routing.
Deep dives
Why augmented skip lists beat B-trees for live rank
A standard B-tree or skip list finds a key in O(log N) time, but cannot tell you *how many items precede it* without walking every node from the head — O(N). Redis implements `ZSET` as a hash table (`member → score`) plus a skip list where each forward link is annotated with its *span* (the number of level-0 nodes it jumps over). Inserting or deleting updates the spans along the O(log N) search path; `ZREVRANK` sums the spans along the path to compute the exact 0-based rank in O(log N) time.
Sharding beyond a single Redis node
25M players occupy ~1.6 GB, so one primary + read replicas usually suffices. If you must shard across M nodes: (1) **Hash-partition by userId**: `ZINCRBY` routes to one node, `top 100` queries top 100 from all M nodes and merges M × 100 items, but exact `rank(userId)` requires asking all M nodes `ZCOUNT(score, +inf)` and summing. (2) **Range-partition by score bucket**: each node owns a score interval and maintains its total count; when a user's score crosses a bucket boundary, move them between two shards and update the bucket counts.
Tie-breaking and tournament rotation
Redis sorts members with identical scores lexicographically by member ID, which feels arbitrary to players. To reward the player who reached the score first, pack `(score, maxTimestamp - achievedAt)` into the 53-bit integer precision of a 64-bit float, or scale scores by `10^10` and add `9999999999 - epochSeconds`. For daily/monthly boards, include the period in the key (`board:2026-10`) so rotation is an instant key switch and old boards expire or archive cleanly.
Failure scenarios
Tournament finale: traffic doubles across the board
Scenario and expected result
During the final minute of a seasonal event (t = 1.2 s to 2.2 s), both score submissions and leaderboard views double to 6,000 rps.
Expected: The 1-second top-100 cache coalesces global board views, and the in-memory sorted set handles the doubled rank and score operations in sub-millisecond time without dropping requests.
The top-100 cache restarts
Scenario and expected result
At t = 1.5 s the top-100 cache is flushed.
Expected: Because `ZREVRANGE 0 99` on the sorted set is already an O(log N + 100) in-memory read (~0.4 ms), the rank store absorbs the cache misses effortlessly while the cache refills.
The durable score ledger slows down 5×
Scenario and expected result
From t = 1.4 s to 2.2 s the relational score ledger experiences a 5× latency spike during a checkpoint.
Expected: All `top` and `rank` reads (80% of traffic) hit only the cache and sorted-set store, so their latency is completely untouched. Score writes take ~15 ms instead of ~3 ms, well within the 60 ms timeout.
Alternative designs
Reference
- Inspect the architecture above and run the same scenario.
SQL ORDER BY / COUNT(*) on the durable database
- No Redis tier to operate: compute a player's rank with `SELECT COUNT(*) FROM scores WHERE score > :myScore`. Even with a B-tree index, counting millions of rows per read scans index pages linearly (unless the DB maintains counted B-trees, which relational engines omit for concurrency). Run this alternative to see the database saturate under rank queries.
Reference
- Inspect the architecture above and run the same scenario.
Partitioned sorted sets: shard by score range (bucketing)
- When a board outgrows one node's memory or write throughput (>100M players or >100k writes/s), sharding by `userId` requires scatter-gather for `ZREVRANK` because no shard knows how many higher scores live on other shards. Sharding by fixed score intervals (e.g., `[0..1000)`, `[1000..2000)`) plus a tiny summary table of shard counts lets a rank lookup sum the counts of higher shards and query `ZREVRANK` on the player's own shard.
Reference
- Inspect the architecture above and run the same scenario.
Approximate percentiles and histograms for massive casual games
- Keep exact sorted sets only for the top 10,000 players and maintain coarse score-bucket counters (or a t-digest / HDR histogram) for everyone else, returning 'Top 4.2%' instead of '#1,048,291'. Cuts memory by 100× when exact tail ranks do not matter.
Clarifying questions
- How many active players are on a single leaderboard, and what is the peak score update rate?
- Are scores cumulative (incremented per match) or high-water-mark (best single run)?
- How should ties be broken — earlier timestamp wins?
- Do we need exact global rank for a player ranked #18,000,000, or is exact rank only needed for the top tier?
Common mistakes
- Running `SELECT COUNT(*) FROM scores WHERE score > ?` on a relational database at scale.
- Assuming 25M players require a complex 50-node sharded cluster without calculating that 25M × 64 B is ~1.6 GB.
- Sharding a sorted set by `userId` and forgetting that `ZREVRANK` on one shard only gives the shard-local rank.
- Letting mobile clients call the score update endpoint directly instead of only accepting signed game-server reports.
- Storing only in Redis with no durable ledger to rebuild from after an accidental flush or region loss.
Self-assessment rubric
requirements / levels 1-4
- 1/4 Show top players.
- 2/4 Adds score updates and player's own rank.
- 3/4 Latency (<100 ms), DAU scale, tie-breaking, and seasonal/daily board rotation.
- 4/4 Distinguishes global exact rank from friend leaderboards and approximate percentile tiers.
estimates / levels 1-4
- 1/4 None.
- 2/4 QPS only.
- 3/4 Calculates memory (~64 B × 25M ≈ 1.6 GB) and read/write QPS.
- 4/4 Proves a 25M-user board fits in a single node's RAM and computes O(log N) op capacity.
api / levels 1-4
- 1/4 Submit and get top.
- 2/4 Adds get user rank.
- 3/4 Includes idempotent matchId on score updates and relative window (`±k` neighbours).
- 4/4 Addresses anti-cheat server-to-server signing so clients cannot post raw scores.
data model / levels 1-4
- 1/4 Scores table.
- 2/4 Adds index on score.
- 3/4 Pairs an in-memory sorted set (`ZSET`) with a durable score ledger.
- 4/4 Encodes deterministic timestamp tie-breaking inside the score representation.
high level / levels 1-4
- 1/4 API + SQL DB.
- 2/4 Adds Redis.
- 3/4 Sorted-set store for live rank, short-TTL top-K cache, durable ledger + profile hydration.
- 4/4 Explains recovery/rebuild of the sorted set from the durable ledger or change stream.
deep dive / levels 1-4
- 1/4 None.
- 2/4 Mentions Redis ZSET commands.
- 3/4 Explains augmented skip lists with span counters for O(log N) rank.
- 4/4 Compares user-hash sharding vs score-range sharding when scaling past one node.
wrap-up / levels 1-4
- 1/4 Stops.
- 2/4 Mentions monitoring.
- 3/4 Identifies Redis failover and tournament-end spikes as main risks.
- 4/4 Describes async write-behind via Kafka for extreme write spikes and reconciliation checks.