Design a real-time leaderboard/ranking system for millions of users
Redis sorted sets turn rank, top-N, and score updates into logarithmic operations instead of full-table scans.
LEARN BY DOINGPut your intuition to work3 steps
Design a real-time leaderboard/ranking system for millions of users
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why this isn't just "SELECT ... ORDER BY score DESC"
A leaderboard sounds like a solved problem until you write down the actual requirements: get a user's rank among millions of players in milliseconds, get the top 100 in milliseconds, get "the 5 players around me" in milliseconds, and accept thousands of score updates per second — all while the ranking changes continuously. A relational database can answer any one of these with an index, but ORDER BY score DESC LIMIT 100 OFFSET 4500000 to find where a specific user sits is a full scan of everything ahead of them, and doing that on every score update, for every player, does not scale.
The right data structure is a sorted set: a collection where every member has a score, the structure keeps itself ordered by score, and rank/insert/update are all logarithmic. Redis ships this as a first-class type (ZSET), backed by a skip list plus a hash table internally, which is exactly why it's the standard answer to this question.
#The core operations
import Redis from "ioredis";const redis = new Redis(process.env.REDIS_URL);const LEADERBOARD_KEY = "leaderboard:global:season-2024";// Set (or overwrite) a player's score.export async function submitScore(userId: string, score: number): Promise<void> {await redis.zadd(LEADERBOARD_KEY, score, userId);}// Accumulate points instead of overwriting (e.g. "+10 for this match").export async function incrementScore(userId: string, delta: number): Promise<number> {return redis.zincrby(LEADERBOARD_KEY, delta, userId);}// 1-based rank, highest score first.export async function getRank(userId: string): Promise<number | null> {const rank = await redis.zrevrank(LEADERBOARD_KEY, userId); // 0-based, descendingreturn rank === null ? null : rank + 1;}// Top N players with their scores.export async function getTopN(n: number) {const raw = await redis.zrevrange(LEADERBOARD_KEY, 0, n - 1, "WITHSCORES");const results: Array<{ userId: string; score: number; rank: number }> = [];for (let i = 0; i < raw.length; i += 2) {results.push({ userId: raw[i], score: Number(raw[i + 1]), rank: i / 2 + 1 });}return results;}// "Players around me" — a window centered on the requesting user's rank.export async function getAroundMe(userId: string, radius: number) {const rank = await redis.zrevrank(LEADERBOARD_KEY, userId);if (rank === null) return [];const start = Math.max(0, rank - radius);const stop = rank + radius;const raw = await redis.zrevrange(LEADERBOARD_KEY, start, stop, "WITHSCORES");const results: Array<{ userId: string; score: number; rank: number }> = [];for (let i = 0; i < raw.length; i += 2) {results.push({ userId: raw[i], score: Number(raw[i + 1]), rank: start + i / 2 + 1 });}return results;}
ZADD, ZINCRBY, ZRANK/ZREVRANK, and ZRANGE/ZREVRANGE are all O(log N), which is what makes this viable at "millions of users": a score update for one player never touches the other 4,999,999 rows.
#Making "update score, then read rank" atomic
A subtle correctness bug: if your API updates the score and then makes a second round trip to read the new rank, another player's score change can land in between, and you'll report a rank that was already stale by the time you sent it. Push both operations into a single Lua script, which Redis executes atomically:
const INCR_AND_RANK = `redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2])local rank = redis.call('ZREVRANK', KEYS[1], ARGV[2])local score = redis.call('ZSCORE', KEYS[1], ARGV[2])return {score, rank}`;export async function incrementAndGetRank(userId: string, delta: number) {const [score, rank] = (await redis.eval(INCR_AND_RANK,1,LEADERBOARD_KEY,delta,userId)) as [string, number];return { score: Number(score), rank: rank + 1 };}
#Ties, and why they matter more than they seem to
Two players with score 1000 will otherwise be ordered arbitrarily by Redis (insertion order within the skip list), which looks like a bug to users — "why is he ranked above me, we're tied?" The standard fix is to encode a tiebreaker into the score itself. If earlier achievement should win ties, store a composite score: score * 10^13 + (MAX_TIMESTAMP - achieved_at), so that among equal scores, the one achieved earlier sorts first, while the visible, real score is still recoverable by integer division. This trick — packing a secondary sort key into the primary one — comes up constantly in interviews once you mention it, because it avoids a second field and a client-side merge sort.
#Scaling past one Redis key
A single sorted set handles tens of millions of members fine on modern hardware — memory is the real constraint, not the operation cost, since each member costs roughly 100+ bytes including the skip-list pointers. Real scale problems show up differently:
- Many independent leaderboards (per-game, per-season, per-region, per-guild) are naturally sharded: each gets its own key, and Redis Cluster spreads keys across nodes by hash slot automatically. This is the common case and needs no special design.
- One truly global, extremely hot leaderboard — the whole platform ranked on one key — becomes a hot-key problem: every write and every read hits the same shard. The usual fix is a write-behind pattern: each app server locally batches/aggregates score deltas for a short window (say 1 second) and flushes a single
ZINCRBYper user per window instead of one per event, cutting write volume by orders of magnitude. - Persistence:
ZSETs live in memory. Enable Redis persistence (AOF or RDB snapshots) so a restart doesn't wipe the leaderboard, and treat a separate durable store (a database table updated asynchronously) as the source of truth if losing the leaderboard entirely would be unacceptable rather than just inconvenient.