Realtime Leaderboard
Maintain top-N rankings and a player's rank among millions, updated live.
Open the interactive version → diagrams, practice & moreRequirements
Functional
- Update score
- Get top-N
- Get my rank & neighbors
Non-functional
- Realtime
- Scales to millions
Scale
Millions of players, frequent updates
The approach
A sorted set (Redis ZSET) gives O(log n) score updates and O(log n) rank queries; shard by league/region; periodic persistence to a DB.
Key components
App → Redis sorted set (per league) → DB snapshot
Numbers that matter
- O(log N) for both ZADD (score update) and ZRANK (rank lookup) in Redis sorted sets — N=10M members costs ~23 comparisons.
- ~50 bytes per member in a Redis ZSET (member string + score float + skip-list overhead) — 10M players ≈ ~500MB per shard.
- ~100K writes/sec per Redis node is a practical ceiling for ZADD-heavy workloads before you need sharding or batching.
- <10ms P99 leaderboard read latency is achievable serving from Redis ZRANGEBYSCORE with WITHSCORES for a top-100 slice.
Senior deep-dive
Redis ZSET is the right data structure — O(log N) score updates and O(log N) rank queries make it purpose-built for this problem at millions of members.
Sharding by league/region is the escape valve; a single global ZSET for 100M players is a memory and throughput bottleneck on one Redis node.
Exact global rank is expensive — most products show 'top 1%' or a local window around you, not a precise rank-among-all, because ZRANK across a sharded set requires merging counts from every shard.
ZSET is the primitive — don't reinvent it
Redis sorted sets store (member, score) pairs in a skip list + hash table internally, giving O(log N) insert/update and O(log N) rank in one data structure. The alternatives — a SQL ORDER BY with a rank() window function, or a manually maintained sorted list — both require either a full-table scan or complex maintenance on every score change. ZSET is purpose-built; use it.
Sharding strategy: league buckets beat hash sharding
Hash-sharding by userId distributes write load evenly but makes global rank a scatter-gather: you must ask every shard 'how many members scored above X?' and sum. League/tier bucketing (top 1% → Grandmaster, 1-10% → Diamond, etc.) keeps each ZSET bounded and makes rank meaningful within a competitive peer group. Most games already have this concept; map it to your shard key. Promotions/demotions move users between ZSETs, which is a delete + add — cheap.
Write batching: don't ZADD on every event
A player scoring a point in a high-velocity game might generate thousands of score events per second globally. Writing each to Redis directly is wasteful and will saturate the ZADD throughput. Batch-accumulate score deltas in memory (or a local queue) and flush periodically (1-5s). Leaderboards tolerate a few seconds of staleness — nobody expects to see their rank update with each individual action. Batching also enables deduplication: if a score event arrives twice (at-least-once delivery), summing deltas and doing one ZADD is idempotent.
Persistence: Redis as cache, DB as source of truth
Redis is ephemeral by default — an AOF/RDB snapshot gives partial durability, but a Redis failure means rebuilding the ZSET from the durable store. Keep scores in a relational or wide-column DB as the authoritative record; Redis is a precomputed read layer. On restart, replay from the DB: `SELECT user_id, score FROM scores WHERE league='Diamond' ORDER BY score DESC` → bulk ZADD. The rebuild cost is bounded per league, not global.
Surroundings query: show your neighbors
The most valuable UX is not rank 1-100 but 'you are #4,382, here are #4,380-#4,384' — your personal context. Redis ZREVRANK gives your rank in O(log N); ZRANGEBYSCORE with a score window gives your neighbors. The challenge: if two players have the same score, ZRANK returns the same rank but in arbitrary order — you need a tiebreaker (e.g., encode `score * 10^9 + (MAX_TIME - achieved_at)` as the ZSET score to break ties by who achieved it first).
What breaks at scale
Hot key contention on a single global ZSET: every write goes to one Redis node; at 100K+ concurrent players it saturates. Score fanout storms hit during end-of-season resets when millions of scores are recomputed simultaneously. Memory growth without TTL-based expiry of inactive members: a ZSET with 50M historical players balloons even though 90% haven't played in a year — add a periodic cleanup job that removes members with last-active > threshold. Finally, clock skew between application servers producing the tiebreaker timestamp can cause non-deterministic ordering.
In production
Games like Fortnite and League of Legends use Redis sorted sets as the canonical leaderboard tier, with writes batched and periodically flushed to durable storage (Postgres/DynamoDB) for persistence across Redis restarts. The real engineering challenge is the rank query under a sharded design: if players are spread across 10 Redis nodes by `userId % 10`, getting a user's global rank requires asking all 10 shards how many players have a higher score, then summing — that's a scatter-gather on the read path. Most production systems avoid true global rank by using league-based bucketing (Diamond/Gold/Silver), which confines each ZSET to a bounded, manageable population.
Common mistakes
- Sorting a table on every read
- One global structure (hotspot)
- Recomputing rank by scanning