Skip to content

Leaderboard

intermediateCommon atAmazonMetaGoogleRobloxRiot GamesRead firstSorted containersHeapCachingDistributed thinkingPractise in the Sandbox →

A leaderboard answers three questions fast: who’s on top, what’s my rank, and who’s around me. A heap gives you the top but not “my rank”. An ordered structure with subtree counts gives you both, and in production that is a Redis sorted set: a skip list plus a hash table, where ZINCRBY and ZREVRANK are O(log N) and the top K is O(log N + K). One sorted set holds about 100M players in ≈10 GB. The hard part starts when one node isn’t enough: shard by player and every rank query scatters to all shards; shard by score range and the top shard runs hot. Past that, you keep the top exact and serve everyone else an approximate rank from score buckets.

Games, fitness apps, coding contests and sales dashboards all show ranked lists. A mobile game with 100M players wants a global all-time board, a weekly and a daily one, maybe per region and per friend group, all updated live as matches finish.

The goals conflict:

Goal What it means Pulls toward
Live A finished match shows in the ranking within a second or two Update the ranking on every score event
Exact at the top #1, #2, #3 must be right; prizes depend on it Strict ordering, deterministic ties
“My rank” for everyone Each of 100M players sees their own position Rank queries in O(log N), not O(N)
Cheap at scale Millions of reads per minute during events Caching, approximation for the long tail
Fair No self-reported scores, no double counting Server-authoritative scores, idempotent updates
In scope (typical) Ask about
Functional Submit score; top K; rank of a player; players around a player; daily, weekly and all-time boards. Is the score cumulative (sum of points) or a best (max single run)? Friends-only boards?
Scale 100M players, 50k score updates/s at peak, 20k reads/s. Players on each board, or active players? Peak event traffic?
Latency Read under 50 ms p99; an update visible within ~1 s. Must “my rank” be exact, or is “top 3%” acceptable below the top 10k?
Ties Deterministic: earlier achiever ranks higher, or shared rank (“1, 2, 2, 4”). Which tie rule? It changes the score encoding.
Durability No score is lost; the ranking can be rebuilt. Is the ranking the source of truth, or a view?
Out of scope Matchmaking, anti-cheat ML, rewards payout.

Drag the assumptions. Memory is rarely the problem; throughput per shard is, and the moment you need more than one shard, rank queries change shape.

Back of the envelope
drag the assumptions
assumptions
so you need
memory, one board10 GBskip list + hash per member
memory, all windows30 GBdaily/weekly boards hold only active players, so this is an upper bound
sorted-set writes per second150K/s
shards needed for throughput (50% headroom)4above 1, rank queries must work across shards
comparisons per ZINCRBY / ZREVRANK26.6expected skip-list depth: O(log N)
shard calls/s if hashed by player230K/seach rank read asks every shard "how many score higher?"
100M players at ≈100 bytes is 10 GB for one board, which fits on one machine. But 50k updates/s fanned out to 3 windows is 150k sorted-set writes/s, more than one Redis primary handles comfortably with headroom. That's the number that forces sharding.

What to say out loud: “One sorted set fits 100M players in about 10 GB, and every operation is O(log N), about 27 steps. Memory is fine. Writes across three windows are around 150k a second, so I need a few shards, and then I have to decide how a rank query works when no single shard sees everyone.”

pseudocode: the contracts
POST /scores { match_id, player_id, points } -- from the game server only
GET /boards/{board}/top?k=10 -> [ { rank, player, score } ]
GET /boards/{board}/rank/{player} -> { rank, score, percentile }
GET /boards/{board}/around/{player}?n=5 -> [ { rank, player, score } ] -- n above, n below
board key "lb:{game}:{window}:{period}" e.g. lb:g1:weekly:2026-W46
sorted set member = player_id, score = encoded score (a double)
score events { event_id, player_id, points, at } -- durable log, dedupe on event_id
score DB (board, player_id) -> total, updated_at -- source of truth

Two decisions hide in that sketch:

  • The sorted set is a view, not the record. Scores land in a durable log and a database first. The sorted set is rebuilt from them if lost. That keeps Redis free to be fast and lets you replay.
  • Ties are encoded into the score. Redis orders equal scores by member name, which is arbitrary. For “earlier achiever wins”, pack time into the score: encoded = points × 2^32 + (2^32 − 1 − seconds_since_season_start). A double is exact up to 2^53, so that leaves 21 bits (≈2M) for points. For shared ranks (“1, 2, 2, 4”), compute a player’s rank as 1 + the number of players with a strictly higher score: ZCOUNT key (score +inf.

Step through each flow. Writes go through a log, so they can be deduped and replayed; reads are a handful of O(log N) commands.

Leaderboard: write, read, rotate, recover
step through
flow
Scores come from the server, flow through a log, and land in both the database and the sorted sets.
ZINCRBYPlayers (game client): clientPlayersgame clientGame server (validates match): serviceGame servervalidates matchScore events (Kafka): queueScore eventsKafkaScore updater (idempotent · ×N): workerScore updateridempotent · ×NScore DB (source of truth): storeScore DBsource of truthLeaderboard API (×N): workerLeaderboard API×NRedis ZSETs (per board · sharded): cacheRedis ZSETsper board · shardedWindow job (rotate · snapshot): workerWindow jobrotate · snapshot
matchm_8812
playerp_42
points+350
The client never reports its own score. The game server decides the result of a match, which is the main anti-cheat control.
1 / 4
Component Role Why it’s there
Game server Decides match results and emits score events. Clients can’t be trusted with their own scores.
Score events (Kafka) Durable, partitioned by player. Ordering per player, replay after failures, decouples bursts.
Score updater (×N) Dedupes by event id, writes the DB, updates each window’s sorted set. Exactly-once effect from at-least-once delivery (idempotency).
Score DB Totals per (board, player). Source of truth; rebuild input; history.
Redis sorted sets One key per board and period, sharded if needed. O(log N) rank and range queries in memory.
Leaderboard API (×N) Stateless reads; caches the top K for a second. The top list is identical for everyone, so cache it.
Window job Snapshots finished periods, sets expiry on old keys. Rewards and history without unbounded memory.

The sorted containers page covers ordered structures in depth. The short version for this design:

Structure Update score Top K Rank of a player Verdict
Sort on every read O(1) O(N log N) O(N log N) Fine for 1,000 players, nothing more
Heap of size K O(log K) O(K log K) Not supported Top-only boards
Balanced BST / skip list with counts O(log N) O(log N + K) O(log N) The answer
SQL ORDER BY score with an index O(log N) O(log N + K) O(N): counts rows above Rank is the problem

Redis implements a sorted set as a skip list plus a hash table: the hash maps member → score in O(1); the skip list keeps members ordered and stores span counts on its links, so walking down to a member also sums how many members it passed. That sum is the rank. The commands you’ll use:

Command Does Cost
ZINCRBY key delta member add to a cumulative score O(log N)
ZADD key GT score member keep the best score only (Redis 6.2+) O(log N)
ZREVRANK key member 0-based rank, highest first O(log N)
ZRANGE key 0 9 REV WITHSCORES top 10 O(log N + 10)
ZCOUNT key (s +inf how many score above s O(log N)
ZSCORE key member / ZCARD key score / size O(1)

A SQL table can do top K with an index but not rank: SELECT COUNT(*) WHERE score > ? scans everything above you, O(N) for a player in the middle. That one gap is why leaderboards live in Redis (or a custom order-statistic tree) even when everything else lives in Postgres.

Deep dive: sharding when one node isn’t enough

Section titled “Deep dive: sharding when one node isn’t enough”

Once writes exceed one primary, you have to split players across shards, and you have two choices. Run both:

Sharding a leaderboard: by player or by score?
simulator
2,000 players. Each tick: 50 score updates (40% from the top 10%), 10 top-10 reads, 20 rank reads.
shard 1
500
25%
shard 2
500
25%
shard 3
500
24%
shard 4
500
25%
players on shardshare of all calls so far
shard calls per top-10: 4.0shard calls per rank: 4.0players moved between shards: 0hottest shard: 25% of calls (even = 25%)
players heldload near evenhot shard
Hashing by player spreads players and writes evenly and never moves anyone. The price is on reads: the top 10 is the merge of every shard's top 10, and a player's rank is the sum of "how many score higher" over all S shards. Raise S and watch calls per read grow with it.
tick 40 / 40
Shard by player hash Shard by score range
Write 1 shard, even load 1 shard; a move (remove + add) when crossing a band
Top K Scatter: top K from each of S shards, merge S × K 1 shard (the top band)
My rank Scatter: sum of ZCOUNT (s +inf over S shards Local ZREVRANK + cached sizes of the bands above
Hot spots None The top band: strongest players play most and every top-K read lands there
Rebalancing Consistent hashing, rarely needed Bands drift as scores grow; boundaries must move
pseudocode: exact rank when players are hashed across S shards
rank(board, player):
s <- ZSCORE on shard_of(player)
above <- parallel for each shard i: ZCOUNT board (s +inf -- O(log N_i) each
return 1 + sum(above) -- S calls, one round trip

The scatter is one parallel round trip, so with 4-16 shards it’s often fine. For top K, cache the merged result for a second: it’s the same for everyone. Score-range sharding is worth it only when reads dominate and you can afford to split the top band further (e.g., the top 1% on its own, over-provisioned shard).

A common hybrid: a single small “top” sorted set with only players above a threshold (say the top 10k), exact and unsharded, plus hash-sharded sets for everyone, plus a bucket histogram for approximate rank. That’s the next section.

Deep dive: approximate rank, windows and persistence

Section titled “Deep dive: approximate rank, windows and persistence”

Approximate rank. Most players don’t need “#48,213”; they need “top 12%”. Keep a count of players per score bucket. Rank ≈ players in higher buckets + an interpolated share of your own bucket. A few hundred counters, updated with one HINCRBY per score change, replace a scatter across every shard:

Approximate rank from score buckets
interactive
Players per bucket (score increases to the right). The highlighted bucket holds the queried player.
score 036024
score 1592exact rank #5,996estimated #6,784 (+788)memory: 20 counters vs 20,000 entries
Error (estimated − exact) across all players, best rank on the left:
+1764−1764
mean relative error: 10.7%worst absolute error: 1,770 placesworst error in the top 100: 11
higher buckets (counted whole)player's bucket (interpolated)queried player
Equal-width buckets: most players crowd into the low buckets, so a player in the crowded middle can be off by over a thousand places. Quantile buckets hold the same number of players each, which caps the error near N / (2B) places, but the top bucket then stretches over a huge score range and the top 100 get badly wrong ranks. Neither layout is good at the top, which is where a wrong number hurts most: someone at #3 must not see #40. Real systems keep the top few thousand exact in a sorted set and use buckets only for "you're in the top 12%". Interpolation helps both by assuming scores spread evenly inside a bucket.
pseudocode: approximate rank from bucket counters
on score change (old -> new):
if bucket(old) != bucket(new): HINCRBY hist bucket(old) -1 ; HINCRBY hist bucket(new) +1
approx_rank(score):
b <- bucket(score)
above <- sum of hist[j] for j > b -- keep a prefix sum, refreshed every few s
frac <- (upper(b) - score) / (upper(b) - lower(b))
return above + ceil(frac * hist[b])

Choose buckets where players are: equal-width buckets waste resolution on the empty top end; quantile buckets fix the middle but blur the top. Hence the hybrid: exact for the top N, buckets for the rest.

Time windows. A daily board is just a separate key per period: lb:g1:daily:2026-11-14. Each score event updates every window it counts toward (3 ZINCRBYs for daily, weekly, all-time). At midnight writes move to the new key; nothing is reset. Set an EXPIRE on old keys after snapshotting the final standings. A rolling “last 7 days” board can be computed with ZUNIONSTORE of 7 daily keys (O(N) + O(M log M), so run it every few minutes into a cached key, not per request). Pick one timezone (usually UTC) and say so, or each region needs its own period boundaries; see time handling.

Persistence. Redis with AOF and a replica protects against most failures, but the sorted sets are still a derived view. The durable log plus the score DB are the truth:

  1. Every score event has an id; the updater records processed ids (or the last offset per partition) so replays don’t double-count.
  2. A lost replica is resynced from the primary. A lost primary fails over to its replica, losing at most the last few writes of async replication.
  3. Those writes are repaired by replaying the log from a checkpoint. Idempotent updates make the replay safe.
  4. If everything is gone, rebuild by scanning the DB and ZADDing in batches of a few thousand. Meanwhile serve the last cached top list.
What breaks What you see Design response
Duplicate score events Players gain points twice Dedupe on event id in the updater; idempotent writes
Redis primary fails Writes error for seconds Replica promotion; replay the log from the last checkpoint
Hot top shard (range sharding) p99 for top-K reads climbs Cache top K for ~1 s; split or over-provision the top band
Scatter read to a slow shard Rank latency = slowest shard Timeouts, hedged requests, fall back to approximate rank
Celebrity/event spike Read QPS 50× Top K served from cache or CDN; rank via buckets
Score overflow in encoded ties Wrong order at high scores Check bit budget: points × 2^32 must stay under 2^53
Cheating Impossible scores at #1 Server-authoritative scores, plausibility checks, delayed publish of top ranks
Midnight rollover Burst of “new day” writes, empty board New key per period, pre-create, show yesterday’s final until today has data
Operation Time Space
Update score (ZINCRBY) O(log N): ≈27 levels at 100M ≈100 B per member (skip list node + hash entry)
Top K (ZRANGE … REV) O(log N + K) —
Rank (ZREVRANK) O(log N) on one set; S × O(log N/S) hash-sharded —
Around me O(log N + 2n) —
Approximate rank O(B) or O(1) with a cached prefix sum B counters
Rolling window union O(N) + O(M log M) per rebuild One extra set
Throughput ≈100k O(log N) ops/s per Redis primary (order of magnitude) 100M players ≈ 10 GB per board

A good 45-minute arc:

  1. Clarify (5 min). Cumulative or best score, tie rule, which windows, exact rank for everyone or only the top, scale.
  2. Data structure (5-10 min). Why a heap fails for rank and SQL fails for rank; ordered structure with counts; Redis sorted set and its costs.
  3. Write path (10 min). Server-authoritative score events, log, idempotent updater, DB as the source of truth, one ZINCRBY per window.
  4. Read path (5 min). Top K cached, rank, around-me with a range read, profile lookups.
  5. Scale (10 min). Estimate per-shard throughput; hash vs range sharding and how rank works in each; hybrid exact top + approximate buckets.
  6. Stretch (5 min). Windows and rollover, rebuild from DB, friends leaderboards (a small set per player, or fetch friends’ scores and sort, since friend lists are small).

Follow-ups to expect: “Two players tie.” Encode time into the score, or use competition ranking via ZCOUNT. “Show rank for all 500M players.” Exact top 10k, percentile from buckets for everyone else. “Redis is lost.” Replay the log into a rebuilt set; the DB is the truth. “Friends leaderboard.” Friend lists are small (hundreds), so ZMSCORE the friends from the global board and sort them in the API.

Check yourself
pick one
Why can't a min-heap of size K alone power a leaderboard screen that shows the top 10 and 'your rank: #48,213'?
Check yourself
pick one
Players are hash-sharded over 8 Redis shards. How do you compute an exact rank for a player with score s?
Check yourself
pick one
You shard by score range so top-K reads hit one shard. After a month, that shard is at 90% CPU and others are idle. Why?

Concept pages to drill: Sorted containers · Heap · Segment tree · Idempotency · Caching · Time handling · Distributed thinking