Skip to content

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. Uses ZRANGE ... REV (the ZREVRANGE family is deprecated since 6.2, see Sorted Sets).

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.

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.

cli/sets-sorted-sets-leaderboards-1.redis
127.0.0.1:6379> ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"
(integer) 5
127.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) 5
ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"
ZRANGE game:lb 0 9 REV WITHSCORES

Step 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:

cli/sets-sorted-sets-leaderboards-2.redis
127.0.0.1:6379> ZREVRANK game:lb "alice" WITHSCORE
1) (integer) 2
2) "3200"
127.0.0.1:6379> ZREVRANK game:lb "carol"
(integer) 4
127.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) 3
127.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) 4
127.0.0.1:6379> ZREVRANK game:lb "dave"
(integer) 0

Note 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"

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).

cli/sets-sorted-sets-leaderboards-3.redis
127.0.0.1:6379> ZRANGE game:lb 0 1 REV WITHSCORES
1) "dave"
2) "5800"
3) "bob"
4) "4100"
127.0.0.1:6379> ZRANGE game:lb 2 3 REV WITHSCORES
1) "alice"
2) "4000"
3) "carol"
4) "3250"
127.0.0.1:6379> ZRANGE game:lb 4 5 REV WITHSCORES
1) "eve"
2) "2900"
127.0.0.1:6379> ZREVRANK game:lb "eve"
(integer) 4
127.0.0.1:6379> ZRANGE game:lb 3 5 REV WITHSCORES
1) "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).

cli/sets-sorted-sets-leaderboards-4.redis
127.0.0.1:6379> ZADD ties:plain 3200 alice 3200 bob
(integer) 2
127.0.0.1:6379> ZRANGE ties:plain 0 -1 REV
1) "bob"
2) "alice"
127.0.0.1:6379> ZADD ties:fixed 3200999999899 alice 3200999999949 bob
(integer) 2
127.0.0.1:6379> ZRANGE ties:fixed 0 -1 REV WITHSCORES
1) "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 WITHSCORES
1) "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.

cli/sets-sorted-sets-leaderboards-5.redis
127.0.0.1:6379> ZADD lb:day:1 100 alice 50 bob
(integer) 2
127.0.0.1:6379> ZADD lb:day:2 30 alice 80 bob 10 carol
(integer) 3
127.0.0.1:6379> ZADD lb:day:3 20 carol 40 bob
(integer) 2
127.0.0.1:6379> ZUNIONSTORE lb:week 3 lb:day:1 lb:day:2 lb:day:3
(integer) 3
127.0.0.1:6379> ZRANGE lb:week 0 -1 REV WITHSCORES
1) "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) 3
127.0.0.1:6379> ZRANGE lb:week:w 0 -1 REV WITHSCORES
1) "bob"
2) "210"
3) "alice"
4) "130"
5) "carol"
6) "50"
127.0.0.1:6379> EXPIRE lb:week 604800
(integer) 1
127.0.0.1:6379> ZUNIONSTORE lb:max 3 lb:day:1 lb:day:2 lb:day:3 AGGREGATE MAX
(integer) 3
127.0.0.1:6379> ZRANGE lb:max 0 -1 REV WITHSCORES
1) "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 WITHSCORES

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):

cli/sets-sorted-sets-leaderboards-6.redis
127.0.0.1:6379> ZREMRANGEBYRANK game:lb 0 -4
(integer) 2
127.0.0.1:6379> ZRANGE game:lb 0 -1 REV WITHSCORES
1) "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”
src/sets-sorted-sets-leaderboards-top.ts
// @run
import { 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 atomic
await redis.zincrby(lb, 1500 * SCALE, 'carol');
// top N: FLAT array [member, score, ...] with string scores
async 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+1
const 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 double
await 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 members
console.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.

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.

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.

ChoiceBenefitCost
Sorted Set for a leaderboardReal-time ranking, O(log N) write and rank, no ORDER BYOne float64 score: ties and secondary criteria must be encoded
ZINCRBY for scoringAtomic, safe with many writersNo history of who awarded what; store events separately if you need an audit
Tie-break encoded in the scoreDeterministic “first to arrive wins” with no extra lookupLimits the point range (< 9,000,000 with a 1e9 time part) and changes what you display (decode it)
Rank pages by offsetTrivial arithmetic, O(log N + page)Pages shift when players overtake each other between requests
ZUNIONSTORE weekly roll-upReuses daily boards, weights and MAX for freeO(N) + O(M log M), run on a schedule and TTL the result
  • “ZRANK is the rank of the best player.” It counts from the lowest score; ZREVRANK is 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 * 1e9 breaks above 9 million points.
  • Using ZADD when awarding points — it overwrites. Use ZINCRBY.
  • 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; ZREMRANGEBYRANK after 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).

Which command fetches the top 10 players in descending score order (current, non-deprecated form)?
ZREVRANK returns 0 for which player?
Why is ZINCRBY preferred over ZADD when awarding points?
Carol has 1750 and you run ZINCRBY lb 2500 carol. What is her new score?
Two players have 3200 points. With plain point scores, how does ZRANGE lb 0 -1 REV order them?
You use score = points * 1e9 + tiebreak. What is the limit on points?
In ZUNIONSTORE week 3 d1 d2 d3 WEIGHTS 1 1 2, what does the weight 2 do?