Real-Time Leaderboard Caching with Redis
Leaderboards need to update instantly when scores change, handle millions of entries, and return rankings in milliseconds. Redis sorted sets make this possible. Here's how to build production-grade leaderboards.
Why Sorted Sets Are Perfect
Redis sorted sets (ZSET) provide:
- O(log N) insert and update
- O(log N) rank lookup
- O(log N + M) range queries (M = result size)
- Automatic ordering by score
A sorted set with 10 million players still returns top 100 in under 1ms.
Basic Leaderboard Operations
// Add or update score
await redis.zadd('leaderboard:global', score, playerId);
// Get player's rank (0-indexed, highest score first)
const rank = await redis.zrevrank('leaderboard:global', playerId);
// Get player's score
const score = await redis.zscore('leaderboard:global', playerId);
// Get top 10 players with scores
const top10 = await redis.zrevrange('leaderboard:global', 0, 9, 'WITHSCORES');
// Returns: ['player1', '9500', 'player2', '9200', ...]
// Get players ranked 50-60
const page = await redis.zrevrange('leaderboard:global', 49, 59, 'WITHSCORES');
Handling Score Updates
For games where scores accumulate:
// Increment score (atomic)
await redis.zincrby('leaderboard:global', pointsEarned, playerId);
// Update only if higher (for high scores)
async function updateHighScore(playerId, newScore) {
const currentScore = await redis.zscore('leaderboard:global', playerId);
if (!currentScore || newScore > parseFloat(currentScore)) {
await redis.zadd('leaderboard:global', newScore, playerId);
return true; // New high score!
}
return false;
}
Atomic operations matter: Use ZINCRBY instead of GET + SET to avoid race conditions when multiple score updates happen simultaneously.
Time-Based Leaderboards
Most leaderboards reset daily, weekly, or monthly:
function getLeaderboardKey(period) {
const now = new Date();
switch (period) {
case 'daily':
return `leaderboard:daily:${now.toISOString().slice(0, 10)}`;
case 'weekly':
const week = getWeekNumber(now);
return `leaderboard:weekly:${now.getFullYear()}-W${week}`;
case 'monthly':
return `leaderboard:monthly:${now.toISOString().slice(0, 7)}`;
case 'alltime':
return 'leaderboard:alltime';
}
}
// Update all relevant leaderboards
async function recordScore(playerId, score) {
const pipeline = redis.pipeline();
pipeline.zincrby(getLeaderboardKey('daily'), score, playerId);
pipeline.zincrby(getLeaderboardKey('weekly'), score, playerId);
pipeline.zincrby(getLeaderboardKey('monthly'), score, playerId);
pipeline.zincrby(getLeaderboardKey('alltime'), score, playerId);
await pipeline.exec();
}
Adding Player Context
Leaderboards need more than just scores—show names, avatars, levels:
async function getLeaderboardWithDetails(period, start, count) {
const key = getLeaderboardKey(period);
// Get ranked player IDs with scores
const rankings = await redis.zrevrange(key, start, start + count - 1, 'WITHSCORES');
// Extract player IDs
const playerIds = rankings.filter((_, i) => i % 2 === 0);
// Batch fetch player details (cached)
const players = await getPlayersCached(playerIds);
// Combine rankings with player data
return playerIds.map((id, i) => ({
rank: start + i + 1,
playerId: id,
score: parseInt(rankings[i * 2 + 1]),
name: players[id].name,
avatar: players[id].avatar,
level: players[id].level
}));
}
async function getPlayersCached(playerIds) {
const pipeline = redis.pipeline();
for (const id of playerIds) {
pipeline.hgetall(`player:${id}`);
}
const results = await pipeline.exec();
return Object.fromEntries(
playerIds.map((id, i) => [id, results[i][1]])
);
}
Showing Player's Neighborhood
Show players around the current user's rank:
async function getPlayerNeighborhood(playerId, radius = 5) {
const key = getLeaderboardKey('daily');
const rank = await redis.zrevrank(key, playerId);
if (rank === null) return null;
const start = Math.max(0, rank - radius);
const end = rank + radius;
const neighborhood = await redis.zrevrange(key, start, end, 'WITHSCORES');
return {
playerRank: rank + 1,
players: formatLeaderboard(neighborhood, start)
};
}
Scaling to Millions
Strategies for massive leaderboards:
- Shard by region: Separate leaderboards per geography
- Approximate ranking: Use percentiles for lower ranks
- Lazy cleanup: Remove inactive players periodically
// Clean up inactive players (run daily)
async function cleanupInactivePlayers(period, daysInactive) {
const cutoff = Date.now() - (daysInactive * 24 * 60 * 60 * 1000);
const key = getLeaderboardKey(period);
// Get players not active recently
const inactive = await redis.zrangebyscore(
'player:lastActive',
0,
cutoff
);
if (inactive.length > 0) {
await redis.zrem(key, ...inactive);
}
}
Real-time leaderboards at any scale
Cachee.ai handles leaderboard operations for games with millions of concurrent players.
Start Free Trial