SYSTEM_DESIGN
System Design: Real-Time Leaderboard System
Design a real-time leaderboard system supporting millions of players with instant rank updates, global and friend-filtered views, and consistent rankings under high write concurrency. Covers Redis Sorted Sets, sharding strategies, and rank computation at scale.
Requirements
Functional Requirements:
- Players earn scores through game events; scores update in real time after each match
- Global leaderboard: rank any player among all participants, retrieve top-N, and fetch players near a given rank
- Friend leaderboard: show a player's rank among only their friends (personalized)
- Seasonal leaderboards: reset scores on a schedule (daily, weekly, monthly, seasonal)
- Historical leaderboards: access past season rankings without affecting the live board
- Leaderboard segments: separate boards per game mode, region, and player tier
Non-Functional Requirements:
- Score updates reflected in global leaderboard within 1 second of match completion
- Support 100 million players on a single global leaderboard
- Rank query latency under 10ms at p99
- Write throughput: 50,000 score updates per second during peak match-end bursts
- Ties broken by timestamp (earlier achiever ranks higher)
Scale Estimation
100M players on a global leaderboard: a Redis Sorted Set holding 100M members, each with a 64-bit score and ~20-byte member string, requires ~3.5 GB of Redis memory — fits in a single Redis instance. However, a single Redis instance handling 50k writes/second (ZADD calls) is feasible (Redis can handle 100k simple ops/second), so the naive single-instance approach works for moderate scale. Beyond 50k writes/second or 500M members, sharding is needed. Friend leaderboards require computing a personalized rank from a potentially large friend graph — a player with 1,000 friends requires fetching 1,000 scores and ranking among them, which is O(N log N) per query.
High-Level Architecture
The leaderboard system is built around Redis Sorted Sets as the primary data structure. A Redis Sorted Set stores (player_id → score) mappings with O(log N) ZADD and ZRANK operations. The global leaderboard is a single Sorted Set per segment (game_mode × region × season). Score updates are written directly to Redis by the score update service after each match. The score update service is a stateless API layer that receives match result events from Kafka (published by the game server when a match ends), computes the score delta, and issues ZADD NX GT (add only if new score is greater) to Redis. The GT flag ensures monotonically increasing scores — a player can never lose rank by playing more.
Tie-breaking by timestamp requires encoding both score and time in the Sorted Set score. Use the composite score: composite_score = score * 10^10 + (MAX_TIMESTAMP - achieved_at_unix_ms). This stores both in a single float64, with score as the high-order bits and recency (inverted) as the low-order bits. Two players with the same score will be ranked by who achieved it earlier (lower MAX_TIMESTAMP - time = higher composite).*
Friend leaderboards use a different strategy: rather than maintaining a pre-computed Sorted Set per player's friend list (which would require O(friends) ZADD calls on every score update), friend ranks are computed on-demand using ZSCORE to fetch each friend's score and a client-side sort. For players with up to 500 friends, this is 500 Redis MGET operations (batched) plus an in-process sort — completing in <5ms.
Core Components
Score Update Service
The score update service consumes match result events from Kafka. Each event contains player_id, game_mode, region, score_delta (not absolute score — enables concurrent updates without read-modify-write races). The service uses Redis ZINCRBY for delta updates (atomic increment, O(log N)) rather than fetching the current score and replacing it. This eliminates race conditions when two match results arrive simultaneously for the same player. The service routes updates to the correct Sorted Set key: lb:{game_mode}:{region}:{season_id}. After updating Redis, the service writes the raw score event to ClickHouse for historical analysis.
Rank Query Service
The rank query service exposes read APIs backed entirely by Redis. Key operations: ZREVRANK returns a player's 0-indexed rank from the top (O(log N) = ~27 operations for 100M players), ZREVRANGE ... WITHSCORES returns top-N players (O(log N + N)), ZREVRANGEBYSCORE retrieves players near a given score range (for "show players near me" UI). The service applies a rank offset (+1 to convert 0-indexed to display rank). For the "nearby players" feature (show the 5 players above and below the current player), use ZREVRANK to get the player's rank then ZREVRANGE {rank-5} {rank+5} — two O(log N) Redis calls.
Seasonal Reset Service
Seasons have defined start/end dates stored in PostgreSQL. At season end, the reset service: (1) archives the current Sorted Set to a timestamped key (lb:deathmatch:us:2025-S1-archive), (2) creates a new empty Sorted Set for the new season, (3) updates the active season pointer in Redis string key lb:deathmatch:us:active_season, (4) writes the final top-10,000 of the completed season to PostgreSQL for permanent storage. Historical leaderboard queries for past seasons read from PostgreSQL rather than Redis (saving memory), returning the pre-computed top-N or a specific player's rank from the archived snapshot.
Database Design
Redis: lb:{game_mode}:{region}:{season_id} (Sorted Set, player_id → composite_score), player:{player_id}:score:{game_mode} (current season score hash for fast single-player lookups), lb:{game_mode}:{region}:active_season (string, current season ID). PostgreSQL: leaderboard_seasons (season_id, game_mode, region, started_at, ended_at), season_snapshots (snapshot_id, season_id, rank, player_id, score, achieved_at) — stores top-10k per completed season, score_history (player_id, season_id, final_score, final_rank) — per-player season history. ClickHouse: score_events (player_id, game_mode, score_delta, match_id, occurred_at) — raw score event log for analytics.
API Design
GET /leaderboards/{game_mode}/{region}/top?n={count}&season={id}— returns top-N players with rank, player_id, score; served from Redis ZREVRANGEGET /leaderboards/{game_mode}/{region}/players/{player_id}/rank?season={id}— returns{rank, score, percentile}; Redis ZREVRANK + ZCARDGET /leaderboards/{game_mode}/{region}/players/{player_id}/nearby?window={n}— returns N players above and below; two Redis ZREVRANGE callsGET /leaderboards/{game_mode}/{region}/friends?player_id={id}&season={id}— fetches friend list from social graph service, batch ZSCORE for all friends, returns sorted resultPOST /scores— body:{player_id, game_mode, region, score_delta, match_id}, publishes to Kafka score-updates topic; rate-limited and authenticated
Scaling & Bottlenecks
A single Redis node handles 100M-member Sorted Sets comfortably in 3.5 GB memory and 50k ZADD/second. For 500M players or 500k writes/second, shard the leaderboard by player_id hash (16 shards). With sharding, global rank computation requires querying all 16 shards to count players with higher scores — this is a scatter-gather ZCOUNT score MAX across shards, completing in <5ms with parallel calls. Top-N queries require merging top-N from each shard and re-sorting — a merge of 16 sorted lists of N items is O(N log 16) = fast.
Friend leaderboards with large friend lists (1,000+ friends) require 1,000 Redis MGET calls, which take ~10ms in a pipeline. For players with very large social graphs (top streamers with 10k+ friends as "friends" in a social game), cap the friend leaderboard at the 1,000 highest-scoring friends and compute nightly rather than real-time.
Key Trade-offs
- Redis in-memory vs. persistent database: Redis provides O(log N) rank queries that PostgreSQL cannot match without full table scans or complex materialized rank tables; the cost is memory footprint and the need for Redis persistence (AOF/RDB) for durability.
- Exact rank vs. approximate rank: For very large leaderboards (1B+ players), exact ZREVRANK is still O(log N) and fast; approximate rank using percentile buckets is unnecessary given Redis's efficiency, unless memory is the constraint.
- Real-time updates vs. eventual consistency: Writing score updates synchronously to Redis on match end provides instant rank visibility but creates a write hotspot if a viral game has 1M matches ending simultaneously; Kafka decoupling smooths the write burst at the cost of <1s eventual consistency.
- Composite score encoding for ties: Encoding score and timestamp in a single float64 works well for scores up to 10^9 and timestamps representable in 10 decimal digits, but overflows with higher scores — use Lua scripting in Redis to implement a proper lexicographic comparison if score ranges exceed this.
GO DEEPER
Master this topic in our 12-week cohort
Our Advanced System Design cohort covers this and 11 other deep-dive topics with live sessions, assignments, and expert feedback.