05 - Design a Leaderboard
📋 Jump to Takeaways🎁 Getting rank #1 is easy. Getting the rank of player #4,382,917 out of 10 million in under 50ms is the real challenge.
A leaderboard ranks players (or users) by score in real-time. Think gaming leaderboards, LeetCode contest rankings, or Duolingo weekly leagues. This problem introduces sorted data structures, real-time updates, and the tradeoff between read speed and write speed when rankings change constantly.
Leaderboard Requirements
Leaderboard Functional Requirements
- Submit score: a user completes an action (finishes a game, solves a problem) and their score is recorded. If they already have a score, keep the highest.
- Top-K: show the top 100 players on the leaderboard page
- My rank: a user opens the leaderboard and sees "You are ranked #4,521 out of 10M players"
- Neighbors: show players ranked just above and below you (ranks 4,516–4,526) so you see who to beat next
- Time windows: separate leaderboards for today, this week, and all-time. Weekly resets every Monday.
Leaderboard Non-Functional Requirements
| Metric | Target |
|---|---|
| Total players | 10 million |
| Score updates | 5,000/sec peak (during contests) |
| Read queries | 50,000/sec peak (users checking rankings) |
| Rank accuracy | Real-time, reflects the latest score within 1 second |
| Latency | < 50ms for top-K and rank queries |
| Availability | 99.9% (~8.7 hrs/year, ~43 min/month, ~86 sec/day) |
Leaderboard Scope Exclusions
- Historical leaderboard snapshots (what was the ranking last Tuesday?)
- Anti-cheat / score validation (assume scores are legitimate)
- Social features (friends-only leaderboards)
Leaderboard Estimation
Leaderboard Traffic
- Score updates: 5,000/sec peak
- Rank queries: 50,000/sec peak
- Read:Write ratio: 10:1 (read-heavy, like most systems)
Leaderboard Storage
- 10M players × 100 bytes (user_id, score, metadata) = 1 GB
- This is tiny. The entire leaderboard fits in memory.
Leaderboard Key Insight
The challenge isn't storage or throughput. It's maintaining sorted order across 10M entries with real-time updates. Every score change potentially shifts millions of ranks. The data structure choice is everything.
Leaderboard High-Level Design
┌────────────────────────────────────────────────────────────────┐
│ LEADERBOARD │
├────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Client │────►│ API Server │────►│ Redis │ │
│ │ │◄────│ │ │ Sorted Set │ │
│ └──────────┘ └──────┬───────┘ │ (ZSET) │ │
│ │ │ │ │
│ │ │ score → rank │ │
│ │ │ O(log N) ops │ │
│ │ └──────────────────┘ │
│ │ |
| | ┌──────────────────┐ │
│ └────────────►│ PostgreSQL │ │
| (also writes)│ (user profiles, │ │
│ │ score history) │ │
│ └──────────────────┘ │
└────────────────────────────────────────────────────────────────┘Why Redis Sorted Set (ZSET)?
A sorted set is a data structure that maps members to scores and keeps them sorted by score at all times. Redis provides this natively with O(log N) operations:
| Operation | Redis Command | Time Complexity | What it does |
|---|---|---|---|
| Update score | ZADD |
O(log N) | Add or update a member's score |
| Get top-K | ZREVRANGE 0 K-1 |
O(log N + K) | Return K highest-scoring members |
| Get rank | ZREVRANK |
O(log N) | Return a member's position (0-indexed) |
| Get score | ZSCORE |
O(1) | Return a member's score |
With 10M members, log N ≈ 23 operations. Sub-millisecond on Redis.
Interview tip: You don't need to memorize Redis commands or skip list internals. In an interview, say: "I'd use a Redis Sorted Set. It gives O(log N) rank lookups because it's backed by a skip list, and the whole dataset fits in memory." That's sufficient. The command details below are for when you actually build it.
Leaderboard Deep Dive
Redis Sorted Set Operations
# Submit/update a score (user "alice" scored 4500)
ZADD leaderboard 4500 "alice"
# Get top 10 players with scores
ZREVRANGE leaderboard 0 9 WITHSCORES
→ [("bob", 9800), ("alice", 4500), ("charlie", 4200), ...]
# Get alice's rank (0-indexed, highest score = rank 0)
ZREVRANK leaderboard "alice"
→ 1 (she's #2)
# Get players ranked 45-55 (around alice)
ZREVRANGE leaderboard 44 54 WITHSCORES
# Get total number of players
ZCARD leaderboard
→ 10000000Why ZREV (reverse)? Redis sorts ascending by default (lowest score = rank 0). Leaderboards want highest score first, so we use the REV variants.
Score Update Semantics
When a user submits a new score, what do we keep?
| Strategy | Redis command | Use case |
|---|---|---|
| Always replace | ZADD (default) |
Latest score matters (daily challenge) |
| Keep highest only | ZADD GT (Redis 6.2+) |
Best score matters (arcade high score) |
| Accumulate (sum) | ZINCRBY |
Cumulative points (XP, total kills) |
ZADD GT only updates if the new score is Greater Than the existing one. This is exactly what "high score" leaderboards need. No application-side check required.
ZADD GT leaderboard 4500 "alice" ← sets to 4500
ZADD GT leaderboard 3000 "alice" ← ignored (3000 < 4500)
ZADD GT leaderboard 5200 "alice" ← updates to 5200Choose the strategy based on your product. This is a product decision, not a technical one.
PostgreSQL vs Redis for Rankings
You might think: "Just SELECT * FROM scores ORDER BY score DESC LIMIT 10"
| Operation | PostgreSQL | Redis ZSET |
|---|---|---|
| Top 10 | Fast (index scan) | Fast |
| "What's my rank?" | Slow. SELECT COUNT(*) WHERE score > my_score scans rows |
Fast. ZREVRANK is O(log N) |
| Update score | Fast (single row update) | Fast |
| Rank after update | Must recount, no maintained rank | Automatically maintained |
The killer problem is "what's my rank?". PostgreSQL has no way to answer this without counting all rows with a higher score. At 10M rows, that's slow. Redis ZSET maintains rank implicitly in the data structure.
Multiple Leaderboards
Use separate sorted sets per time window:
leaderboard:alltime ← never expires
leaderboard:weekly:2026-W22 ← reset every Monday
leaderboard:daily:2026-05-27 ← reset every midnightWeekly/daily reset: Create a new key at the start of each period. Old keys expire via TTL or are deleted by a cleanup job. No need to scan and zero out scores.
User's score goes to all active leaderboards:
ZADD leaderboard:alltime 4500 "alice"
ZADD leaderboard:weekly:2026-W22 4500 "alice"
ZADD leaderboard:daily:2026-05-27 4500 "alice"Three writes per score update. Still fast at 5K/sec.
Handling Ties
What if two players have the same score? Redis ZSET breaks ties lexicographically by member name. That's arbitrary.
Better approaches:
| Strategy | How | When to use |
|---|---|---|
| Earlier submission wins | Encode timestamp in score: score * 10^10 + (MAX_TS - timestamp) |
Racing games, contests |
| More recent wins | Encode timestamp: score * 10^10 + timestamp |
Daily challenges |
| Alphabetical (default) | Do nothing, Redis handles it | When ties don't matter |
Composite score trick:
Real score: 4500, submitted at timestamp 1716800000
Composite: 4500_1716800000 (as float: 45001716800000)
ZADD leaderboard 45001716800000 "alice"Higher composite = higher rank. Among equal scores, earlier timestamp = higher composite = higher rank.
Precision warning: Redis scores are 64-bit doubles, which have ~15-16 significant digits. The composite 45001716800000 is 14 digits, so it's safe. But if scores exceed ~100,000 and timestamps are full Unix seconds, you'll lose precision. For very large scores, consider using millisecond offsets from a recent epoch instead of full timestamps.
Persistence and Recovery
Redis is in-memory. What if it crashes?
- Redis persistence (RDB/AOF): Redis can snapshot to disk (RDB) or log every write (AOF). On restart, it reloads from disk. You lose at most 1 second of data with AOF.
- PostgreSQL as source of truth: Write every score update to PostgreSQL too. If Redis dies completely, rebuild the ZSET from PostgreSQL:
-- Rebuild leaderboard from PostgreSQL
SELECT user_id, score FROM scores ORDER BY score DESC;
-- Pipe into ZADD commandsAt 10M rows, rebuilding takes ~30 seconds. Acceptable for a rare failure.
Scaling Beyond One Redis Instance
At 10M members and 50K reads/sec, a single Redis instance handles it easily (Redis does 100K+ ops/sec). But if you grow to 100M members:
| Problem | Solution |
|---|---|
| Data doesn't fit in one instance's memory | Shard by leaderboard type (alltime on shard 1, weekly on shard 2) |
| Need global rank across shards | Can't easily shard a single leaderboard. Keep it on one big instance |
| Read throughput bottleneck | Redis read replicas for rank queries |
Key insight: A single leaderboard is hard to shard because rank is a global property. If you split users across shards, you can't compute global rank without querying all shards. Keep one leaderboard per Redis instance and scale vertically (more RAM) or use replicas for reads.
API Design
POST /api/leaderboard/:name/scores
Body: { "user_id": "alice", "score": 4500 }
Response: 200 { "rank": 2 }
GET /api/leaderboard/:name/top?limit=10
Response: [{ "user_id": "bob", "score": 9800, "rank": 1 }, ...]
GET /api/leaderboard/:name/rank/:user_id
Response: { "user_id": "alice", "score": 4500, "rank": 2 }
GET /api/leaderboard/:name/around/:user_id?range=5
Response: [{ "user_id": "dave", "rank": 44 }, ..., { "user_id": "alice", "rank": 47 }, ...]Hydrating display data (enriching IDs with full details from another source): Redis stores only user_id and score. Leaderboards display names, avatars, and badges. After getting the top-K user_ids from Redis, batch-fetch profiles from PostgreSQL (or a user cache). This is a cheap secondary lookup. 10 user profiles from a cache is sub-millisecond.
Key Takeaways
Redis Sorted Set is the go-to data structure for leaderboards
O(log N) for updates, rank lookups, and range queries. The entire 10M-member leaderboard fits in ~1 GB of RAM. No other data store gives you maintained rank order with this performance.
"What's my rank?" is the hard query
PostgreSQL can do top-K easily (index scan). But computing a user's rank requires counting all rows above them, which is O(N). Redis ZSET answers this in O(log N) because rank is maintained implicitly in the skip list structure.
Use separate keys for time-windowed leaderboards
Daily/weekly leaderboards are separate sorted sets. Reset = create a new key. No scanning, no zeroing out scores. Old keys expire via TTL.
Composite scores solve tie-breaking
Encode both score and timestamp into a single numeric value. Redis sorts by this composite, giving you deterministic tie-breaking without extra logic.
A single leaderboard is hard to shard
Rank is a global property. You can't compute it without seeing all scores. Keep one leaderboard on one Redis instance and scale with replicas for reads. Shard across leaderboard types, not within one.
PostgreSQL backs up Redis for durability
Redis is the fast path (reads + writes). PostgreSQL is the durable source of truth. If Redis dies, rebuild from PostgreSQL in ~30 seconds. Belt and suspenders.
🎁 What stops a single client from hammering your API 10,000 times per second and taking down the whole system?