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 rankTry it Yourself »
Discussion
Loading…