Skip to content
All case studies

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.

ClientsLoad balanc…Leaderboard…Leaderboard…Top-100 cac…Sorted-set …Durable sco…
Arrows show permitted calls. Open the canvas to inspect route programs.
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

Non-functional

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

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.

Open this scenario

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.

Open this scenario

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.

Open this scenario

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.
Open this alternative

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

Common mistakes

Self-assessment rubric

requirements / levels 1-4
  1. 1/4 Show top players.
  2. 2/4 Adds score updates and player's own rank.
  3. 3/4 Latency (<100 ms), DAU scale, tie-breaking, and seasonal/daily board rotation.
  4. 4/4 Distinguishes global exact rank from friend leaderboards and approximate percentile tiers.
estimates / levels 1-4
  1. 1/4 None.
  2. 2/4 QPS only.
  3. 3/4 Calculates memory (~64 B × 25M ≈ 1.6 GB) and read/write QPS.
  4. 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. 1/4 Submit and get top.
  2. 2/4 Adds get user rank.
  3. 3/4 Includes idempotent matchId on score updates and relative window (`±k` neighbours).
  4. 4/4 Addresses anti-cheat server-to-server signing so clients cannot post raw scores.
data model / levels 1-4
  1. 1/4 Scores table.
  2. 2/4 Adds index on score.
  3. 3/4 Pairs an in-memory sorted set (`ZSET`) with a durable score ledger.
  4. 4/4 Encodes deterministic timestamp tie-breaking inside the score representation.
high level / levels 1-4
  1. 1/4 API + SQL DB.
  2. 2/4 Adds Redis.
  3. 3/4 Sorted-set store for live rank, short-TTL top-K cache, durable ledger + profile hydration.
  4. 4/4 Explains recovery/rebuild of the sorted set from the durable ledger or change stream.
deep dive / levels 1-4
  1. 1/4 None.
  2. 2/4 Mentions Redis ZSET commands.
  3. 3/4 Explains augmented skip lists with span counters for O(log N) rank.
  4. 4/4 Compares user-hash sharding vs score-range sharding when scaling past one node.
wrap-up / levels 1-4
  1. 1/4 Stops.
  2. 2/4 Mentions monitoring.
  3. 3/4 Identifies Redis failover and tournament-end spikes as main risks.
  4. 4/4 Describes async write-behind via Kafka for extreme write spikes and reconciliation checks.