Leaderboard System Design: Real-Time Rankings for Millions of Players
Every competitive game needs a leaderboard. Players want to know where they stand — globally, among friends, and across seasons. What looks like a simple sorted list becomes a serious engineering challenge when millions of concurrent players expect rank updates in real time.
Functional Requirements#
Before diving into architecture, pin down what the leaderboard must do:
- Submit score — Record a player's score after a match or event.
- Get rank — Return a player's current rank and score.
- Top-K — Fetch the top N players (e.g., top 100).
- Nearby ranks — Show players ranked just above and below a given player.
- Time windows — Support daily, weekly, seasonal, and all-time leaderboards.
- Friend leaderboards — Rank a player against their social graph.
Non-Functional Requirements#
- Serve rank queries with latency under 50 ms at the 99th percentile.
- Handle tens of thousands of score updates per second.
- Support hundreds of millions of total players with millions online concurrently.
- Tolerate brief inconsistency (eventual consistency is acceptable for global ranks).
The Naive Approach and Why It Breaks#
A SQL table with ORDER BY score DESC works for small games. Computing rank means counting all rows with a higher score:
SELECT COUNT(*) + 1 AS rank
FROM scores
WHERE score > (SELECT score FROM scores WHERE player_id = ?)
At 100 million rows, this full-table scan takes seconds, not milliseconds. Indexing helps reads but score updates still cause index churn. SQL alone cannot meet the latency or throughput requirements.
Redis Sorted Sets (ZSET) — The Core Building Block#
Redis sorted sets store members with floating-point scores and keep them ordered in a skip list. Every operation that matters is O(log N):
| Operation | Command | Complexity |
|---|---|---|
| Add/update score | ZADD leaderboard score player_id | O(log N) |
| Get rank | ZREVRANK leaderboard player_id | O(log N) |
| Get score | ZSCORE leaderboard player_id | O(1) |
| Top-K | ZREVRANGE leaderboard 0 K-1 WITHSCORES | O(log N + K) |
| Nearby | ZREVRANGE leaderboard rank-5 rank+5 WITHSCORES | O(log N + K) |
With 100 million members, log N is roughly 27. A single Redis instance can handle hundreds of thousands of ZSET operations per second.
Score Encoding Trick#
When two players share the same score, you want the earlier achiever ranked higher. Encode the score as a composite float:
effective_score = actual_score + (1 - timestamp / MAX_TIMESTAMP)
The fractional part is tiny enough to preserve integer score ordering while breaking ties by time.
Scaling Beyond a Single Redis Instance#
A single ZSET tops out around 500 million members before memory becomes a bottleneck (roughly 50-60 GB for 500M entries). Beyond that, partition the leaderboard.
Score-Range Partitioning#
Split the score space into ranges: shard 0 holds scores 0-999, shard 1 holds 1000-1999, and so on. To compute a global rank:
- Identify which shard the player belongs to.
- Get the player's rank within that shard (
ZREVRANK). - Sum the cardinalities (
ZCARD) of all higher shards. - Global rank = sum of higher shard sizes + intra-shard rank + 1.
This requires K calls (one per shard) but each is O(1) for ZCARD and O(log N) for ZREVRANK. With 10-20 shards the fan-out is negligible.
Consistent Hashing for Player Lookup#
Maintain a lightweight lookup (player_id to shard) so score updates route to the correct shard. When a score update moves a player across shard boundaries, remove from the old shard and insert into the new one — both atomic ZSET operations.
Time-Windowed Leaderboards#
Games need daily, weekly, and seasonal boards. Two strategies:
Separate ZSETs Per Window#
Create keys like lb:daily:2026-03-28, lb:weekly:2026-W13, and lb:alltime. On each score event, update all relevant keys. Use Redis TTL to auto-expire old daily and weekly boards.
Rolling Windows with Bucketed Aggregation#
For sliding windows (e.g., "last 7 days"), store per-day ZSETs and compute a union on read:
ZUNIONSTORE lb:rolling:7d 7 lb:day:03-22 lb:day:03-23 ... lb:day:03-28
This is expensive at scale. A better approach: pre-compute the union in a background job every few minutes and serve the cached result. Players tolerate a few minutes of staleness on weekly boards.
Friend Leaderboards#
Fetching ranks among friends cannot use a global ZSET efficiently — you would need to look up each friend individually. Options:
- Small friend lists (under 200): Pipeline
ZSCOREcalls for all friends, sort client-side. Latency stays under 10 ms with pipelining. - Large friend lists: Maintain a per-player friend ZSET updated asynchronously via a fan-out worker. When player A scores, enqueue updates to all friend leaderboards that include A.
The fan-out cost is proportional to average friend count. For a game with an average of 50 friends, a score event triggers 50 additional ZADD calls — easily handled by Redis.
Top-K Queries at Scale#
For the global top 100, avoid hitting the partitioned ZSETs on every request. Instead:
- A background job runs
ZREVRANGE 0 99on each shard every few seconds. - Merge the shard results into a single sorted list.
- Cache the merged top-100 in a simple Redis key or in-memory on the API servers.
- Serve from cache. Staleness of a few seconds is invisible to players.
Anti-Cheat and Score Validation#
A leaderboard is only as trustworthy as its score pipeline.
Server-Authoritative Scoring#
Never trust client-reported scores. The game server computes the score based on match state and submits it directly to the leaderboard service. The client never calls the score-submission endpoint.
Anomaly Detection#
Flag scores that are statistical outliers:
- Score exceeds the 99.9th percentile by more than 3 standard deviations.
- Player's score jumped by more than X% in a single session.
- Score was submitted from a suspicious IP range or device fingerprint.
Flagged scores go into a review queue rather than the live leaderboard.
Rate Limiting#
Cap the number of score submissions per player per time window. A player submitting 1000 scores per minute is either a bot or exploiting a bug.
Persistence and Recovery#
Redis is an in-memory store. Protect against data loss:
- RDB snapshots every 5 minutes for point-in-time recovery.
- AOF (Append Only File) with
everysecfsync for durability within a 1-second window. - Replica sets with automatic failover via Redis Sentinel or Redis Cluster.
- Source-of-truth in a relational database. Write every score event to PostgreSQL (or DynamoDB) asynchronously. If Redis fails catastrophically, rebuild ZSETs from the durable store.
Eventual Consistency Tradeoffs#
Global rank accuracy is not critical to the millisecond. Acceptable tradeoffs:
- Stale top-K caches: Refresh every 5-10 seconds. Players rarely notice.
- Cross-shard rank lag: Shard cardinalities are cached and refreshed periodically. A player's global rank may be off by a handful of positions for a few seconds.
- Async friend board updates: A friend's score may take 1-2 seconds to appear on your friend leaderboard.
For the player's own rank and score, use synchronous reads against their home shard — this must be accurate.
Architecture Diagram (Text)#
Player Device
|
v
Game Server (score validation)
|
v
Score Ingestion Service
|--- async ---> PostgreSQL / DynamoDB (durable store)
|--- sync ----> Redis Cluster (partitioned ZSETs)
|--- async ---> Friend Board Fan-out Workers
|
v
Leaderboard Query Service
|--- top-K cache (in-memory / Redis key)
|--- per-shard rank lookups
|--- friend board reads
|
v
Player Device (rank, top-K, nearby)
Key Metrics to Monitor#
| Metric | Target |
|---|---|
| ZADD latency p99 | under 5 ms |
| ZREVRANK latency p99 | under 5 ms |
| End-to-end rank query p99 | under 50 ms |
| Score ingestion throughput | more than 50K/sec |
| Redis memory per 100M members | 50-60 GB |
| Top-K cache staleness | under 10 seconds |
Summary#
Leaderboard system design is a textbook case of choosing the right data structure. Redis sorted sets give you O(log N) rank operations out of the box. Partition by score range to scale horizontally, use time-keyed ZSETs for windowed boards, fan out to friend boards asynchronously, and cache top-K results aggressively. Pair Redis with a durable store for recovery, validate scores server-side, and accept eventual consistency where it does not hurt the player experience.
Built with Codelit — the system design tool for engineers who think visually.
This is article #205 in the Codelit engineering blog series.
Try these templates
Uber Real-Time Location System
Handles 5M+ GPS pings per second using H3 hexagonal geospatial indexing.
6 componentsReal-Time Collaborative Editor
Notion-like document editor with real-time collaboration, conflict resolution, and rich media.
9 componentsE-Commerce Checkout System
Production checkout flow with Stripe payments, inventory management, and fraud detection.
11 componentsContinue learning
Go deeper on system design
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Building Microservices
Sam Newman · 2021
600 pages on splitting a monolith: service boundaries, data decomposition, migration order.
Enterprise Integration Patterns
Gregor Hohpe, Bobby Woolf · 2003
A catalog of 65 messaging patterns with diagrams - the vocabulary queue designs still use today.
4.6 (581)Learning Domain-Driven Design
Vlad Khononov · 2021
Turns bounded contexts and aggregates into decision rules for where service boundaries actually go.
4.6 (400)Building Event-Driven Microservices
Adam Bellemare · 2025
2025 rewrite on event schemas, topic ownership, and what breaks when teams share streams.
4.7 (8)Kindle edition