← Back to Blog

Real-Time Leaderboard Caching with Redis

December 22, 2025 • 6 min read • Gaming & Analytics

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:

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:

  1. Shard by region: Separate leaderboards per geography
  2. Approximate ranking: Use percentiles for lower ranks
  3. 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