Updated May 27, 2026

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
→ 10000000

Why 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 5200

Choose 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 midnight

Weekly/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 commands

At 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?

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy