Leaderboards & Rankings

Build real-time leaderboards with Redis sorted sets. Perfect for games, competitions, and analytics.

Why Redis for Leaderboards?#

  • Sorted sets — automatic ranking by score
  • O(log n) — fast insertions and lookups
  • Real-time — instant updates
  • Range queries — get top 10, nearby ranks, etc.

Basic Leaderboard#

TypeScript
import Redis from 'ioredis'; const redis = new Redis(process.env.ARCTICKEY_URL); class Leaderboard { constructor(private key: string) {} // Add or update a player's score async setScore(playerId: string, score: number) { await redis.zadd(this.key, score, playerId); } // Increment score (e.g., +10 points) async addScore(playerId: string, points: number) { return redis.zincrby(this.key, points, playerId); } // Get player's rank (0-indexed, lowest score = rank 0) async getRank(playerId: string): Promise<number | null> { const rank = await redis.zrevrank(this.key, playerId); return rank !== null ? rank + 1 : null; // 1-indexed } // Get player's score async getScore(playerId: string): Promise<number | null> { const score = await redis.zscore(this.key, playerId); return score ? parseFloat(score) : null; } // Get top N players async getTop(n: number): Promise<Array<{ player: string; score: number; rank: number }>> { const results = await redis.zrevrange(this.key, 0, n - 1, 'WITHSCORES'); return this.parseResults(results, 1); } // Get players around a specific player async getAround(playerId: string, range: number = 5) { const rank = await redis.zrevrank(this.key, playerId); if (rank === null) return []; const start = Math.max(0, rank - range); const end = rank + range; const results = await redis.zrevrange(this.key, start, end, 'WITHSCORES'); return this.parseResults(results, start + 1); } private parseResults(results: string[], startRank: number) { const entries = []; for (let i = 0; i < results.length; i += 2) { entries.push({ player: results[i], score: parseFloat(results[i + 1]), rank: startRank + (i / 2), }); } return entries; } } // Usage const leaderboard = new Leaderboard('game:weekly'); await leaderboard.setScore('alice', 1500); await leaderboard.setScore('bob', 2300); await leaderboard.addScore('alice', 100); // Now 1600 const top10 = await leaderboard.getTop(10); // [{ player: 'bob', score: 2300, rank: 1 }, ...] const myRank = await leaderboard.getRank('alice'); // 2

Time-Based Leaderboards#

Weekly, monthly, or daily leaderboards that reset automatically:

TypeScript
function getWeeklyKey(): string { const now = new Date(); const year = now.getFullYear(); const week = getWeekNumber(now); return `leaderboard:weekly:${year}:w${week}`; } function getDailyKey(): string { const now = new Date(); return `leaderboard:daily:${now.toISOString().split('T')[0]}`; } // Set expiry so old leaderboards auto-delete async function addToWeeklyLeaderboard(playerId: string, score: number) { const key = getWeeklyKey(); await redis.zadd(key, score, playerId); await redis.expire(key, 60 * 60 * 24 * 14); // Keep 2 weeks }

Multi-Leaderboard System#

Track multiple scores per player:

TypeScript
class MultiLeaderboard { // Update all leaderboards atomically async recordGame(playerId: string, stats: { score: number; kills: number; wins: number; }) { const multi = redis.multi(); multi.zincrby('lb:score:alltime', stats.score, playerId); multi.zincrby('lb:kills:alltime', stats.kills, playerId); multi.zincrby('lb:wins:alltime', stats.wins, playerId); multi.zincrby(`lb:score:${getDailyKey()}`, stats.score, playerId); await multi.exec(); } // Get player's rankings across all leaderboards async getPlayerRankings(playerId: string) { const [scoreRank, killsRank, winsRank] = await Promise.all([ redis.zrevrank('lb:score:alltime', playerId), redis.zrevrank('lb:kills:alltime', playerId), redis.zrevrank('lb:wins:alltime', playerId), ]); return { score: scoreRank !== null ? scoreRank + 1 : null, kills: killsRank !== null ? killsRank + 1 : null, wins: winsRank !== null ? winsRank + 1 : null, }; } }

Real-Time Updates with Pub/Sub#

Broadcast leaderboard changes:

TypeScript
// When score changes async function updateAndBroadcast(playerId: string, newScore: number) { const oldRank = await leaderboard.getRank(playerId); await leaderboard.setScore(playerId, newScore); const newRank = await leaderboard.getRank(playerId); if (oldRank !== newRank) { await redis.publish('leaderboard:updates', JSON.stringify({ playerId, oldRank, newRank, score: newScore, })); } } // Subscribe to updates (e.g., in WebSocket server) const sub = new Redis(process.env.ARCTICKEY_URL); sub.subscribe('leaderboard:updates'); sub.on('message', (channel, message) => { const update = JSON.parse(message); // Broadcast to connected clients io.emit('leaderboard:update', update); });

With Player Details#

Combine leaderboard with player data:

TypeScript
async function getTopWithDetails(n: number) { const top = await leaderboard.getTop(n); // Fetch player details in parallel const pipeline = redis.pipeline(); for (const entry of top) { pipeline.hgetall(`player:${entry.player}`); } const details = await pipeline.exec(); return top.map((entry, i) => ({ ...entry, details: details[i][1] as Record<string, string>, })); } // Result: // [ // { player: 'bob', score: 2300, rank: 1, details: { name: 'Bob', avatar: '...' } }, // ... // ]

Pagination#

For large leaderboards:

TypeScript
async function getPage(page: number, pageSize: number = 20) { const start = (page - 1) * pageSize; const end = start + pageSize - 1; const [results, total] = await Promise.all([ redis.zrevrange('leaderboard', start, end, 'WITHSCORES'), redis.zcard('leaderboard'), ]); return { entries: parseResults(results, start + 1), page, pageSize, totalPages: Math.ceil(total / pageSize), totalPlayers: total, }; }

Best Practices#

  1. Use meaningful key nameslb:game:mode:period
  2. Set TTLs on time-based boards — auto-cleanup old data
  3. Paginate large results — don't fetch millions of entries
  4. Cache "Top 10" — it changes less often than you'd think
  5. Use pipelines for fetching player details