iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Leaderboards

Sorted sets (ZADD, ZRANGE) make Redis the ideal leaderboard engine: O(log n) insert, O(log n + k) range queries, ranks served in microseconds at any scale. Add periodic resets, tied-score tiebreakers, and pagination for production-ready scoring.

ZADD, ranges, ties, decay, persistence

EXAMPLE
// 1) Add scores
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

await redis.zadd('leaderboard:weekly', 1500, 'user:42');
await redis.zadd('leaderboard:weekly', 2100, 'user:99', 1800, 'user:7');

// Update: ZADD overwrites the score by default.
await redis.zadd('leaderboard:weekly', 'GT', 1600, 'user:42');   // only if new > current
await redis.zadd('leaderboard:weekly', 'LT', 1600, 'user:42');   // only if new < current
await redis.zadd('leaderboard:weekly', 'NX', 1600, 'user:42');   // only if not exists
await redis.zadd('leaderboard:weekly', 'XX', 1600, 'user:42');   // only if exists

// 2) Increment — most natural for points/score additions
await redis.zincrby('leaderboard:weekly', 50, 'user:42');

// 3) Top N — descending
const top10 = await redis.zrevrange('leaderboard:weekly', 0, 9, 'WITHSCORES');
// ['user:99', '2100', 'user:7', '1800', 'user:42', '1500', ...]

// As objects:
const pairs = [];
for (let i = 0; i < top10.length; i += 2) {
    pairs.push({ user: top10[i], score: Number(top10[i + 1]) });
}

// 4) Modern API: ZRANGE with REV
await redis.zrange('leaderboard:weekly', 0, 9, 'REV', 'WITHSCORES');

// 5) User's rank + score
const rank = await redis.zrevrank('leaderboard:weekly', 'user:42');     // 0-indexed (0 = top)
const score = await redis.zscore('leaderboard:weekly', 'user:42');
console.log(`Rank #${rank + 1} with ${score} points`);

// 6) Slice around a user — 'rank #15 + 5 above + 5 below'
const userRank = await redis.zrevrank('leaderboard:weekly', 'user:42');
const start = Math.max(0, userRank - 5);
const end   = userRank + 5;
const neighbours = await redis.zrevrange('leaderboard:weekly', start, end, 'WITHSCORES');

// 7) Score range query (e.g. 'show everyone between 1000 and 2000 points')
await redis.zrangebyscore('leaderboard:weekly', 1000, 2000, 'WITHSCORES', 'LIMIT', 0, 50);

// Inclusive/exclusive:
await redis.zrangebyscore('leaderboard:weekly', '(1000', '2000');  // > 1000, <= 2000

// 8) Count
await redis.zcard('leaderboard:weekly');                   // total players
await redis.zcount('leaderboard:weekly', 1000, '+inf');    // players with > 1000

// 9) Remove
await redis.zrem('leaderboard:weekly', 'user:42');
await redis.zremrangebyrank('leaderboard:weekly', 100, -1);     // keep top 100 only
await redis.zremrangebyscore('leaderboard:weekly', 0, 100);     // drop scores < 100

// 10) Tiebreakers — combine score + timestamp
// Score the tied users by adding a tiny fraction for 'earlier wins':
const now = Date.now();
const score = realScore + (1 - now / 1e15);    // earlier = higher
await redis.zadd('leaderboard', score, 'user:42');

// Or use a sentinel high-resolution score:
const encoded = realScore * 1_000_000_000_000 + (1e12 - now);
await redis.zadd('leaderboard', encoded, 'user:42');

// 11) Weekly / monthly leaderboards with date keys
function weekKey(date = new Date()) {
    const y = date.getUTCFullYear();
    const onejan = new Date(Date.UTC(y, 0, 1));
    const week = Math.ceil(((date - onejan) / 86400000 + onejan.getUTCDay() + 1) / 7);
    return `leaderboard:weekly:${y}-W${week}`;
}

await redis.zincrby(weekKey(), points, userId);

// 12) Score decay / expiry
// Per-key TTL — entire leaderboard expires:
await redis.expire('leaderboard:weekly:2024-W03', 60 * 60 * 24 * 14); // keep 2 weeks

// Time-decayed scores: re-run periodic job:
const all = await redis.zrange('leaderboard', 0, -1, 'WITHSCORES');
for (let i = 0; i < all.length; i += 2) {
    const member = all[i];
    const score  = Number(all[i + 1]);
    const decayed = score * 0.95;                                // 5% decay per period
    if (decayed < 1) await redis.zrem('leaderboard', member);
    else              await redis.zadd('leaderboard', decayed, member);
}

// 13) Aggregations — union / intersection
// Combine weekly leaderboards into a monthly view:
await redis.zunionstore(
    'leaderboard:monthly:2024-01',
    4,
    'leaderboard:weekly:2024-W01',
    'leaderboard:weekly:2024-W02',
    'leaderboard:weekly:2024-W03',
    'leaderboard:weekly:2024-W04',
    'AGGREGATE', 'SUM',
);

// 14) Persistence + replication
// Sorted sets persist via RDB/AOF like everything else.
// For high-throughput leaderboards, use AOF + 1-second fsync; accept potential 1-sec loss.
// Multi-region: keep one source of truth, ship score updates via stream/queue.

// 15) Pagination patterns
// Page 1: rank 0-19
// Page 2: rank 20-39
// ...
//
// For very large leaderboards, cache top N (rarely changes) and compute neighbours dynamically.

// 16) UI helpers
// - 'You moved up 3 spots since yesterday' → compare today's rank with snapshot from 24h ago
// - 'Closest competitors' → 5 ranks above + 5 below
// - 'Personal best this season' → store per-user best in a hash

// 17) Scale
// • One leaderboard with millions of members works fine
// • Cluster: hash-tag the key so all leaderboard slots stay on one shard ({lb}:weekly, {lb}:monthly)
// • If queries dominate writes, replicate read-only nodes

// 18) Common bugs
// • ZADD without 'GT' allows score regression (someone manually decreasing)
// • Forgetting WITHSCORES → only members; lose context
// • Treating zrevrange's array as objects — it's flat [member, score, member, score, ...]
// • Off-by-one in pagination (start, end inclusive)
// • Storing user IDs as numbers but querying as strings — type mismatch; consistent encoding
// • Tied scores show inconsistent order — add tiebreaker into the score
// • Per-week keys without TTL → infinite memory growth
// • Decay job locks the whole leaderboard — chunk via ZSCAN
// • Cluster mode without hash tags — ZUNIONSTORE fails with CROSSSLOT

Why it matters

Redis sorted sets are the canonical leaderboard primitive: O(log n) inserts, ranks in microseconds, weekly/monthly keys with TTL, tiebreakers encoded into the score. Use ZINCRBY for point accumulation, ZREVRANGE for top-N, ZUNIONSTORE to aggregate weeks into months, and pre-cache the top page for read-heavy UIs.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
ZADD scores 5000 player:1 4800 player:2
ZREVRANGE scores 0 9 WITHSCORES   # top 10
ZRANK scores player:1              # global rank
Try it Yourself »

Discussion

Loading…