Leaderboards
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The# cli/...transcripts are one session in document order, replayed by the course harness. UsesZRANGE ... REV(theZREVRANGEfamily is deprecated since 6.2, see Sorted Sets).
The leaderboard pattern
Section titled “The leaderboard pattern”A game or app leaderboard needs: store a score, rank players, add points over time, show a page or “around me”, and roll up a period. A Sorted Set covers all of it with O(log N) writes and O(log N + M) range reads. This lesson is one working session from an empty database.
Step 1 — Add scores and fetch the top N
Section titled “Step 1 — Add scores and fetch the top N”Scores are float64, so they can be points, percentages or encoded values. ZRANGE key 0 9 REV WITHSCORES is the top 10: the indices are rank offsets, 0 9 means places 1 to 10.
127.0.0.1:6379> ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"(integer) 5127.0.0.1:6379> ZRANGE game:lb 0 9 REV WITHSCORES 1) "dave" 2) "5800" 3) "bob" 4) "4100" 5) "alice" 6) "3200" 7) "eve" 8) "2900" 9) "carol"10) "1750"127.0.0.1:6379> ZCARD game:lb(integer) 5ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"
ZRANGE game:lb 0 9 REV WITHSCORESStep 2 — Rank lookup and awarding points
Section titled “Step 2 — Rank lookup and awarding points”ZREVRANK key member [WITHSCORE] is the 0-based place from the top (0 = first); add 1 for display. ZINCRBY adds to the score atomically, so no read-modify-write is needed and concurrent writers never lose points (the Sorted Sets lesson measured 50 of 50 vs 1 of 50). Rank moves immediately:
127.0.0.1:6379> ZREVRANK game:lb "alice" WITHSCORE1) (integer) 22) "3200"127.0.0.1:6379> ZREVRANK game:lb "carol"(integer) 4127.0.0.1:6379> ZINCRBY game:lb 1500 "carol""3250"127.0.0.1:6379> ZINCRBY game:lb 800 "alice""4000"127.0.0.1:6379> ZREVRANK game:lb "carol"(integer) 3127.0.0.1:6379> ZRANGE game:lb 0 4 REV WITHSCORES 1) "dave" 2) "5800" 3) "bob" 4) "4100" 5) "alice" 6) "4000" 7) "carol" 8) "3250" 9) "eve"10) "2900"127.0.0.1:6379> ZRANK game:lb "dave"(integer) 4127.0.0.1:6379> ZREVRANK game:lb "dave"(integer) 0Note the last two lines: ZRANK counts from the lowest score, so the top player dave is rank 4 there and 0 with ZREVRANK. Using the wrong one is the classic off-by-direction bug (see the exercise below).
ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"
ZREVRANK game:lb "carol"
ZINCRBY game:lb 1500 "carol"
ZREVRANK game:lb "carol"Step 3 — Pages and “around me”
Section titled “Step 3 — Pages and “around me””Pagination is arithmetic on rank: page p of size s is ZRANGE key p*s p*s+s-1 REV WITHSCORES. A player’s neighbours are the ranks r-1 .. r+1, where r comes from ZREVRANK. A page past the end is just shorter or empty, and an unknown player has no rank (nil).
127.0.0.1:6379> ZRANGE game:lb 0 1 REV WITHSCORES1) "dave"2) "5800"3) "bob"4) "4100"127.0.0.1:6379> ZRANGE game:lb 2 3 REV WITHSCORES1) "alice"2) "4000"3) "carol"4) "3250"127.0.0.1:6379> ZRANGE game:lb 4 5 REV WITHSCORES1) "eve"2) "2900"127.0.0.1:6379> ZREVRANK game:lb "eve"(integer) 4127.0.0.1:6379> ZRANGE game:lb 3 5 REV WITHSCORES1) "carol"2) "3250"3) "eve"4) "2900"127.0.0.1:6379> ZREVRANK game:lb "ghost"(nil)Locating a rank is O(log N) (the skiplist stores spans), so a deep page costs O(log N + page size). Rank pages shift as players overtake each other between requests; for a stable feed, page by score (BYSCORE with the last score seen as the next bound), because BYSCORE ... LIMIT offset count does walk offset entries.
ZADD game:lb 5800 "dave" 4100 "bob" 4000 "alice" 3250 "carol" 2900 "eve"
ZRANGE game:lb 0 1 REV WITHSCORES
ZRANGE game:lb 2 3 REV WITHSCORES
ZREVRANK game:lb "eve"Step 4 — Ties: who ranks higher on equal points?
Section titled “Step 4 — Ties: who ranks higher on equal points?”With plain point scores, equal scores fall back to the member’s bytes, and with REV that order is reversed: bob before alice. That is arbitrary from a player’s point of view. To make “reached the score first” win, put the time in the low digits of the score:
score = points * 1e9 + (1e9 - 1 - secondsSinceLaunch)
Higher points win; on equal points the smaller time yields the larger low part, so the earlier player ranks higher. Award points later with ZINCRBY key (delta * 1e9) member, which leaves the low part untouched (the tie-break is “who got onto the board first”, not “who reached this exact score first”; the latter needs a script). Decode points with Math.floor(score / 1e9).
127.0.0.1:6379> ZADD ties:plain 3200 alice 3200 bob(integer) 2127.0.0.1:6379> ZRANGE ties:plain 0 -1 REV1) "bob"2) "alice"127.0.0.1:6379> ZADD ties:fixed 3200999999899 alice 3200999999949 bob(integer) 2127.0.0.1:6379> ZRANGE ties:fixed 0 -1 REV WITHSCORES1) "bob"2) "3200999999949"3) "alice"4) "3200999999899"127.0.0.1:6379> ZINCRBY ties:fixed 500000000000 alice"3700999999899"127.0.0.1:6379> ZRANGE ties:fixed 0 -1 REV WITHSCORES1) "alice"2) "3700999999899"3) "bob"4) "3200999999949"The budget is a double’s 53 exact bits: points * 1e9 stays exact while points < 9,000,000 (9e6 * 1e9 = 9e15 < 2^53 = 9.007e15). Above that you must shrink the time part (minutes instead of seconds, say) or the encoding silently rounds.
Step 5 — Daily scores rolled up into a week
Section titled “Step 5 — Daily scores rolled up into a week”Keep one Sorted Set per period and combine them with ZUNIONSTORE dest numkeys key... [WEIGHTS w...] [AGGREGATE SUM|MIN|MAX]. The default sums a member’s scores across the sets; WEIGHTS scale each input; AGGREGATE MAX keeps the best single day. The result is a normal Sorted Set (snapshot), so give it a TTL.
127.0.0.1:6379> ZADD lb:day:1 100 alice 50 bob(integer) 2127.0.0.1:6379> ZADD lb:day:2 30 alice 80 bob 10 carol(integer) 3127.0.0.1:6379> ZADD lb:day:3 20 carol 40 bob(integer) 2127.0.0.1:6379> ZUNIONSTORE lb:week 3 lb:day:1 lb:day:2 lb:day:3(integer) 3127.0.0.1:6379> ZRANGE lb:week 0 -1 REV WITHSCORES1) "bob"2) "170"3) "alice"4) "130"5) "carol"6) "30"127.0.0.1:6379> ZUNIONSTORE lb:week:w 3 lb:day:1 lb:day:2 lb:day:3 WEIGHTS 1 1 2(integer) 3127.0.0.1:6379> ZRANGE lb:week:w 0 -1 REV WITHSCORES1) "bob"2) "210"3) "alice"4) "130"5) "carol"6) "50"127.0.0.1:6379> EXPIRE lb:week 604800(integer) 1127.0.0.1:6379> ZUNIONSTORE lb:max 3 lb:day:1 lb:day:2 lb:day:3 AGGREGATE MAX(integer) 3127.0.0.1:6379> ZRANGE lb:max 0 -1 REV WITHSCORES1) "alice"2) "100"3) "bob"4) "80"5) "carol"6) "20"Weights 1 1 2 made day 3 count double: bob went from 170 to 210 (50 + 80 + 40 * 2). ZUNIONSTORE is O(N) + O(M log M) in the input sizes, so build weekly boards on a schedule, not per request.
ZADD lb:day:1 100 alice 50 bob
ZADD lb:day:2 30 alice 80 bob 10 carol
ZUNIONSTORE lb:week 2 lb:day:1 lb:day:2
ZRANGE lb:week 0 -1 REV WITHSCORESStep 6 — Keep it bounded
Section titled “Step 6 — Keep it bounded”A board rarely needs the whole population. ZREMRANGEBYRANK key 0 -(N+1) drops everything but the top N (ranks count from the lowest score, so 0 -4 removes all but the best 3):
127.0.0.1:6379> ZREMRANGEBYRANK game:lb 0 -4(integer) 2127.0.0.1:6379> ZRANGE game:lb 0 -1 REV WITHSCORES1) "dave"2) "5800"3) "bob"4) "4100"5) "alice"6) "4000"From Node.js: top-N with the result shape, around-me and a weekly roll-up
Section titled “From Node.js: top-N with the result shape, around-me and a weekly roll-up”// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const lb = 'b2:lb:game';await redis.del(lb, 'b2:lb:d1', 'b2:lb:d2', 'b2:lb:week');
// score = points * 1e9 + (1e9 - 1 - secondsSinceLaunch): equal points, earlier player ranks higher.// stays exact while points < 9,000,000 (9e6 * 1e9 < 2^53)const SCALE = 1e9;const encode = (points: number, secs: number) => points * SCALE + (SCALE - 1 - secs);const decode = (score: string) => Math.floor(Number(score) / SCALE);
await redis.zadd(lb, encode(3200, 100), 'alice', encode(3200, 50), 'bob', encode(1750, 10), 'carol', encode(5800, 200), 'dave', encode(2900, 300), 'eve');
// award points: ZINCRBY by points * SCALE keeps the tie-break part untouched and stays atomicawait redis.zincrby(lb, 1500 * SCALE, 'carol');
// top N: FLAT array [member, score, ...] with string scoresasync function top(n: number) { const flat = await redis.zrange(lb, 0, String(n - 1), 'REV', 'WITHSCORES'); const rows: { rank: number; player: string; points: number }[] = []; for (let i = 0; i < flat.length; i += 2) rows.push({ rank: i / 2 + 1, player: flat[i]!, points: decode(flat[i + 1]!) }); return rows;}console.log(await top(3));
// "around me": my rank is 0-based, the window is ranks r-1 .. r+1const r = (await redis.zrevrank(lb, 'alice'))!;console.log('alice is rank', r + 1, 'window:', await redis.zrange(lb, Math.max(0, r - 1), String(r + 1), 'REV'));
// daily -> weekly: ZUNIONSTORE sums the scores per member; weights make today count doubleawait redis.zadd('b2:lb:d1', 100, 'alice', 50, 'bob');await redis.zadd('b2:lb:d2', 30, 'alice', 80, 'bob', 10, 'carol');console.log(await redis.zunionstore('b2:lb:week', 2, 'b2:lb:d1', 'b2:lb:d2', 'WEIGHTS', 1, 2)); // 3 membersconsole.log(await redis.zrange('b2:lb:week', 0, '-1', 'REV', 'WITHSCORES')); // bob 210, alice 160, carol 20
await redis.del(lb, 'b2:lb:d1', 'b2:lb:d2', 'b2:lb:week');await redis.quit();Real output (this Mac): the top 3 as { rank, player, points } objects (dave 5800, carol 3250, bob 3200), alice is rank 4 window: [ 'bob', 'alice', 'eve' ], 3, and [ 'bob', '210', 'alice', '160', 'carol', '20' ]. alice and bob both have 3200 points and bob (earlier, 50 s) ranks above her (100 s). ioredis returns WITHSCORES as a flat string array, so the loop steps by 2 and converts.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes the “your rank” label for a game HUD:
🐛 Spot the bug
async function myRank(player: string) {const rank = await redis.zrank('game:lb', player); // "position on the board"return rank === null ? 'unranked' : `#${rank}`;}Show the answer
Two mistakes in one line. ZRANK counts from the lowest score, so the best player is the last place (in the transcript dave, 5800 points, is ZRANK 4 and ZREVRANK 0); and both are 0-based, so even the correct command would show the winner as #0. Use ZREVRANK and add 1:
async function myRank(player: string) { const rank = await redis.zrevrank('game:lb', player); return rank === null ? 'unranked' : `#${rank + 1}`;}The runnable sets-sorted-sets-leaderboards-top.ts above reports alice is rank 4 this way.
Mental Model
Section titled “Mental Model”A leaderboard is one number per player, kept sorted by the database, and every screen is a slice of ranks: the top is ranks 0..N-1, a page is a window, “around me” is a window centred on ZREVRANK. Anything you want to rank by (ties, time, multiple criteria) must be folded into that one number, within 53 bits.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Sorted Set for a leaderboard | Real-time ranking, O(log N) write and rank, no ORDER BY | One float64 score: ties and secondary criteria must be encoded |
ZINCRBY for scoring | Atomic, safe with many writers | No history of who awarded what; store events separately if you need an audit |
| Tie-break encoded in the score | Deterministic “first to arrive wins” with no extra lookup | Limits the point range (< 9,000,000 with a 1e9 time part) and changes what you display (decode it) |
| Rank pages by offset | Trivial arithmetic, O(log N + page) | Pages shift when players overtake each other between requests |
ZUNIONSTORE weekly roll-up | Reuses daily boards, weights and MAX for free | O(N) + O(M log M), run on a schedule and TTL the result |
Common Misconceptions
Section titled “Common Misconceptions”- “
ZRANKis the rank of the best player.” It counts from the lowest score;ZREVRANKis the leaderboard rank. - “Ranks start at 1.” Both are 0-based; add 1 for display.
- “Ties are broken by who scored first.” By member bytes (reversed with
REV) unless you encode time yourself. - “Timestamp in the score is free.” It spends bits of the 53 you have;
points * 1e9breaks above 9 million points.
Common mistakes
Section titled “Common mistakes”- Using
ZADDwhen awarding points — it overwrites. UseZINCRBY. - Fetching the whole board and slicing in the app — ask for
ZRANGE start stop REV. - Forgetting the tie-break is in the score — decode before display and increment by
delta * 1e9. - Never trimming — an unbounded board grows forever;
ZREMRANGEBYRANKafter writes or on a schedule.
Real-world example
Redis’s own documentation shows leaderboards as a core Sorted Set use case and gives the ranking commands used here (Sorted sets).