Gaming Leaderboard System Design: Why Redis Beats SQL Here

May 27, 202611 min read
interview-prepcareerdsaalgorithms
Gaming Leaderboard System Design: Why Redis Beats SQL Here
TL;DR
  • Redis sorted sets sort on write via a skip list, so ZREVRANGE and ZREVRANK are O(log N) with no per-read recomputation.
  • 25M users fit in ~4 GB of Redis memory, meaning most games never need leaderboard sharding at all.
  • Two-store architecture: PostgreSQL holds the immutable score event log; Redis serves every hot read.
  • Time-windowed leaderboards use one sorted set per epoch with a TTL, so weekly boards expire automatically without a reset.
  • Friend leaderboards use at-read ZSCORE fan-out for small lists; pre-materialized ZINTERSTORE only when lists regularly exceed 500.
  • Hash sharding breaks global rank queries; score-range sharding is the correct approach if sharding becomes unavoidable.
  • The 45-minute system design clock splits into requirements, back-of-envelope, architecture, data model, friend boards, and tradeoffs.

SQL sorts on read. Redis sorts on write. For a leaderboard where reads outnumber writes ten to one, that difference decides the architecture.

A gaming leaderboard sounds simple. It's sorted scores. But build it on a relational database and a post-tournament traffic spike turns your ORDER BY score DESC into a table scan under load. Every. Single. Time.

Gaming Leaderboard System Design cover

Clarify Before You Touch the Whiteboard

Spend the first five minutes on questions. You're not stalling. You're demonstrating that you've been burned by underspecified requirements before.

Questions that actually change the design:

  • Global or segmented? Top 100 globally, friend-circle only, or regional?
  • Who submits scores? Trusted game servers, or clients directly? (Client submission requires anti-cheat validation.)
  • Time windows? All-time only, or daily/weekly/monthly rolling boards?
  • Score model? Cumulative total, or highest-score-wins across attempts?
  • Scale? MAU, peak concurrent users, score update frequency.

Assume for this design: 25M MAU, global leaderboard plus friend-circle boards, all-time and weekly windows, scores submitted by trusted game servers, cumulative totals.

Do the Math Before You Pick a Database

With 25M MAU and 20% DAU at 5 games per active day:

  • Writes: 5M DAU × 5 games = 25M score events/day = ~290/sec average, ~1,500/sec at peak
  • Reads: 10:1 read:write = ~15,000 reads/sec at peak
  • Redis memory: a sorted set entry (24-byte user ID + 8-byte float score + skip list node overhead) runs about 150 bytes. 25M entries = roughly 3.7 GB.

3.7 GB fits on a single Redis node with room to spare. That one number shapes everything. You don't need sharding at this scale, and skipping sharding avoids the hardest problems in this design.

The Core Decision: Why the Redis Leaderboard Sorts on Write

Here's the thing most people miss the first time they design a leaderboard. A SQL ORDER BY score DESC recomputes the sort on every read. Add an index and you narrow the scans, but score updates still trigger index writes and row-level locks under concurrent load. As reads spike, so does lock contention.

SQL sorts on every read. Redis pre-sorts on write. The difference at 15k reads/sec. SQL pays the sort cost 15,000 times per second under peak load. Redis pays it once, on each write.

Redis sorted sets flip this. Every ZADD repositions the member in a skip list in O(log N), so every subsequent read is just a traversal. The sorted set is dual-indexed internally: a skip list for ordering and a hash table for O(1) member lookup by ID. ZREVRANGE and ZREVRANK are O(log N + M) and O(log N) respectively. The sort work happens once, on the write path.

The three commands you'll live in:

# Game server submits final score (cumulative) ZINCRBY leaderboard:global 150 "user:7f3a9c" # Top 10 players with scores ZREVRANGE leaderboard:global 0 9 WITHSCORES # Player's 0-indexed rank from top ZREVRANK leaderboard:global "user:7f3a9c" # 5 players above and 5 below (nearby rank view) rank = ZREVRANK leaderboard:global "user:7f3a9c" ZREVRANGE leaderboard:global (rank - 5) (rank + 5) WITHSCORES

Player display metadata lives in a separate Redis hash, not in the sorted set. HSET user:7f3a9c name "Meridith" avatar "cdn.example.com/...". Keeping the sorted set lean matters for memory and scan speed.

If your game uses highest-score-wins instead of cumulative totals, use ZADD NX (only set if member doesn't exist yet) combined with ZADD GT (only update if new score is greater). Redis 6.2+ added the GT/LT flags for exactly this.

For a closer look at why the skip list makes this work, see Skip List Data Structure. The sorted set's hash table is the same O(1) story covered in Hash Map Time Complexity.

Two Services, Two Stores

Keep it this simple until the interviewer asks you to go deeper.

Game Server
    |
    v
Score Update Service  ---> PostgreSQL  (durable score history)
    |
    v
   Redis               ZINCRBY + HSET on write

Client App
    |
    v
Leaderboard Service  ---> Redis  (ZREVRANGE / ZREVRANK on read)

Two-service architecture: Score Update writes to both stores, Leaderboard reads Redis only Score Update Service is the only writer. Leaderboard Service is the only reader. They never step on each other.

The Score Update Service writes synchronously to both PostgreSQL and Redis. No Kafka needed at 1,500 writes/sec. If you need to fan score events to an analytics pipeline or a push notification service later, drop a queue in. But don't add it before you need it.

The Leaderboard Service never touches the database on the hot read path. Redis only.

What Lives Where

PostgreSQL stores the immutable event log. One row per game completion:

CREATE TABLE score_events ( id BIGSERIAL PRIMARY KEY, user_id UUID NOT NULL, game_id UUID NOT NULL, delta INTEGER NOT NULL, -- points earned this game created_at TIMESTAMPTZ DEFAULT NOW() );

This is your source of truth. If the Redis instance loses data, you rebuild by replaying this table. That's also how you backfill new time-windowed leaderboards.

Redis holds two key namespaces:

Key patternTypePurpose
leaderboard:globalsorted setAll-time global ranking
leaderboard:weekly:2026-W22sorted setISO-week-scoped ranking
user:{id}hashDisplay name, avatar URL

Under the Hood: Skip List + Hash Table

Every Redis sorted set is secretly two data structures at once.

Redis sorted set internals: skip list for ordering, hash table for O(1) score lookup The skip list keeps members sorted by score. The hash table makes ZSCORE O(1). One ZADD writes both atomically.

The skip list gives you ordered traversal in O(log N + M). The hash table gives you direct-by-member score lookup in O(1). ZINCRBY reads the current score from the hash table, computes the new score, then updates both structures in one shot. There's no separate index to maintain, no lock escalation, and no secondary B-tree to keep in sync.

Four Endpoints, One Hot Read Path

Four endpoints cover the core use cases:

POST   /scores
       { userId, gameId, delta }

GET    /leaderboard/top?limit=100
       Returns top N players with rank + score

GET    /leaderboard/players/{userId}
       Returns that player's rank, score, percentile

GET    /leaderboard/players/{userId}/nearby?range=5
       5 players above + 5 below in rank

The top-N response is public and cacheable. The top 100 doesn't change every second. A 5-10 second TTL at the CDN or reverse proxy cuts Redis load significantly during traffic spikes. Individual rank queries are private (not CDN-cacheable) but Redis answers them in under a millisecond.

Weekly Boards Don't Reset. They Expire.

"And how do you handle weekly resets?" asks the interviewer. Most candidates propose a cron job that zeroes out scores at midnight. That's the wrong answer. It's a thundering herd of writes aimed at a single key, exactly when everyone is watching the final standings.

Weekly and monthly boards are separate sorted sets per epoch:

leaderboard:weekly:2026-W22
leaderboard:monthly:2026-05

When a score event arrives, write to all applicable keys at once. The write path hits three sorted sets: global, current week, current month.

Time-windowed leaderboards: new key per epoch with TTL. No reset needed. Start a new key each epoch. Set a TTL. The old board stays readable until it expires on its own.

To clean up old keys automatically, set a TTL when the key is first created: EXPIRE leaderboard:weekly:2026-W22 1209600 (14 days). No cron job required.

The "weekly reset" is not a reset. You start a new key. The old week's board keeps its data until the TTL fires. A player can look up last week's final standings right up to expiry. This is cleaner than zeroing scores in place and avoids the thundering herd.

Friend Boards: Fan-Out on Read

Two approaches. Pick based on typical friend list size.

At-read fan-out (for friend lists under ~300): fetch the requesting user's friend list from the social graph, call ZSCORE leaderboard:global {friendId} for each friend in a Redis pipeline, sort the results in application memory, return. One Redis round-trip, no extra storage, always fresh.

Pre-materialized intersection (for large friend lists, with caching): use ZINTERSTORE to create a temporary sorted set from the intersection of the global leaderboard and a friend-list set. Expensive to maintain on every score update. Avoid this unless friend lists regularly exceed 500 people.

For most games, the at-read approach with a 30-second application-layer cache keyed by user ID is the right answer. A friend leaderboard being 30 seconds stale is fine.

When One Node Isn't Enough

At 25M users you don't need to shard. At 200M+ users, a single r7g.xlarge (32 GB Redis) still fits. At 500M MAU you have a sharding decision to make.

Hash sharding by user ID is the obvious approach and the wrong one for leaderboards. Each shard has a local sorted set, but no shard has the global top N. Worse: computing a player's global rank requires asking every shard "how many users score higher?" and summing the answers. Every rank query becomes a scatter-gather over N nodes.

Score-range sharding partitions by score value: shard 0 holds scores 0-9,999, shard 1 holds 10,000-19,999, and so on. A player's global rank = (total players in higher-score shards) + (ZREVRANK within their shard). You query at most two shards per lookup. The catch is hot shards when score distributions cluster, and you need to rebalance as score distributions shift over time.

Hash sharding vs score-range sharding for leaderboard ranks Hash sharding turns every rank query into a scatter-gather. Score-range sharding keeps it to at most two shards.

The honest answer in an interview: check if a single node fits before designing for sharding. Do the memory math first. Most games don't need to shard the leaderboard, and adding shards means you've traded a simple O(log N) query for a scatter-gather with synchronization overhead.

See The Tradeoff Maze for how to frame these kinds of push-pull tradeoffs when the interviewer pushes on your choices.

Which Path to Pick

DecisionSimple pathComplex path
Write pathSync Redis + DBAsync via Kafka
Score modelZINCRBY cumulativeZADD GT highest-wins
Friend boardAt-read ZSCORE fan-outZINTERSTORE materialized
ShardingSingle node (enough for 200M)Score-range shards
Time windowsNew key per epoch + TTLReset in-place

The sync write path with no queue is correct for thousands of writes per second. Add Kafka when score updates start creating API backpressure, or when multiple downstream consumers need the same events.

The 45-Minute Clock

  • 0-5: Requirements. Global vs friend, time windows, score model, who submits, target scale. State your assumptions aloud.
  • 5-10: Back-of-envelope. Peak writes, peak reads, Redis memory footprint. Conclude "one node fits."
  • 10-20: Two-service architecture. Two stores. Why Redis sorts on write. Core commands.
  • 20-32: Data model. API design. Time-windowed keys with TTL. Write path.
  • 32-42: Friend leaderboards. When sharding becomes necessary and which approach.
  • 42-45: Tradeoffs. Let the interviewer drive toward whatever complexity they care about.

The clock matters because system design interviews reward coverage and tradeoff reasoning over implementation detail. Narrating why you're making each choice is the whole game.


the database is slow let's just use cache for everything - programmer meme That skeptical look is the backend engineer who knows "use Redis for the sorted set" is actually the correct call.


SpaceComplexity runs voice-based mock interviews with rubric-based feedback on exactly this kind of system design and DSA reasoning, so you can practice the narration, not just the diagram.

The Short Version

  • SQL sorts on read, Redis sorts on write. Redis is correct for read-heavy leaderboards.
  • Redis sorted set = skip list + hash table. ZADD, ZINCRBY, ZREVRANK, ZREVRANGE are the four commands to know.
  • 25M users = ~4 GB in Redis. Check if a single node fits before sharding.
  • Two stores: PostgreSQL for durable event history, Redis for live ranking.
  • Time windows = new sorted set per epoch with a TTL. No reset needed.
  • Friend leaderboards: at-read ZSCORE fan-out for small lists, cached for larger ones.
  • Hash sharding destroys rank queries. Score-range sharding is better. Single node is best if scale allows.

Further Reading