Building Leaderboards with Sorted Sets
AI generated
SET
TTL
Redis · Gaming Backend · Data Structures · Backend
Building Leaderboards with Sorted Sets
from ZADD to a paginated ranking

A ranking with thousands of players sounds like a sorting and scaling problem, but with Redis Sorted Sets it is a data structure built for exactly that. ZADD, ZRANK and ZREVRANGE deliver ranking, neighborhood queries and top-N lists in logarithmic time, without the application ever having to sort or cache anything itself.

17 min read ZADD · ZRANK · ZREVRANGE · Pagination Redis 6.x · 7.x

1. Why Sorted Sets for leaderboards

A leaderboard needs to do three things well at once: insert new scores quickly, determine the rank of any player in constant or logarithmic time, and return the top-N players without fully sorting the entire dataset. A relational database with ORDER BY score DESC LIMIT n and an index on the score column works for small datasets, but quickly becomes a bottleneck with frequent writes and millions of players, because every rank query effectively requires counting through the index.

Redis Sorted Sets solve exactly this problem because they are internally implemented as a skip list, a data structure that performs insertion, deletion and rank queries all in O(log n). Every member of a sorted set has a numeric score, and Redis automatically keeps the set sorted by score. For a leaderboard, this means: ZADD updates a score, ZRANK immediately returns the current rank, ZREVRANGE returns the top-N players, all without any separate sorting logic in the application.

This article builds a production ready leaderboard system with sorted sets, from the base operations through tie-breaking and pagination up to time-based rankings that reset daily or weekly.

2. ZADD basics and score design

The base operation for every leaderboard is ZADD leaderboard:global score member. If the same member is added again, Redis simply updates its score instead of creating a duplicate entry, which makes ZADD the natural choice for constantly changing scores. For incremental scoring instead of an absolute reset, ZINCRBY leaderboard:global 10 player:42 increases the existing score by a value, without the application needing to read the current value first.

Score design determines the quality of the leaderboard. A simple integer score is enough for many cases, but on ties Redis sorts lexicographically by member name by default, which is rarely the desired behavior. A common technique encodes secondary criteria directly into the score, for example by folding an inverted timestamp of the achievement into the lower digits, so that with the same primary score the earlier player automatically ends up on top, with no extra application logic at all.


# Basic leaderboard operations
redis-cli> ZADD leaderboard:global 15420 "player:42"
(integer) 1
redis-cli> ZADD leaderboard:global 18990 "player:17"
(integer) 1

# Incrementing a score without reading it first
redis-cli> ZINCRBY leaderboard:global 250 "player:42"
"15670"

# Current member count and score lookup
redis-cli> ZCARD leaderboard:global
(integer) 2
redis-cli> ZSCORE leaderboard:global "player:42"
"15670"

3. Ranking with ZRANK and ZREVRANK

ZRANK returns a member's zero-based rank in ascending score order, ZREVRANK in descending order, which is the relevant direction for most leaderboards, since the highest score should mean rank one. Both operations run in O(log n), regardless of whether the sorted set holds a hundred or ten million members, which is the decisive advantage over a naive sort in the application layer.

To display a human readable rank, the application only needs to add one to the zero-based result. A common mistake is calling ZREVRANK individually for every visible player on each page load, instead of extracting the rank information directly from a ZREVRANGE query with WITHSCORES, which delivers position and score in a single roundtrip. For leaderboards with high request frequency, this difference is the difference between one Redis call and dozens per page load.

4. Top-N with ZREVRANGE WITHSCORES

The top-N query is the most common operation on a leaderboard and is handled in a single Redis call with ZREVRANGE leaderboard:global 0 9 WITHSCORES: the ten best players along with their scores, already sorted correctly. Since this query runs in O(log n + m), where m is the number of returned elements, it stays fast even with millions of players in the sorted set, because only the requested slice is actually traversed, not the entire structure.

In newer Redis versions, ZRANGE leaderboard:global 0 9 REV WITHSCORES is the recommended, unified syntax meant to eventually replace ZREVRANGE, but is functionally identical. For a leaderboard frontend that refreshes the top-10 list every few seconds, this single operation is entirely sufficient, with no additional caching of the result list needed, because Redis itself already responds faster than any external cache layer.


<?php
declare(strict_types=1);

final class Leaderboard
{
    public function __construct(private readonly \Redis $redis, private readonly string $key) {
    }

    /** Returns the top N players with rank, member and score. */
    public function topN(int $limit): array
    {
        $raw = $this->redis->zRevRange($this->key, 0, $limit - 1, true);

        $result = [];
        $rank = 1;
        foreach ($raw as $member => $score) {
            $result[] = ['rank' => $rank++, 'player' => $member, 'score' => (int) $score];
        }
        return $result;
    }

    /** Returns a single player's rank (1-based) and score, or null if absent. */
    public function playerRank(string $member): ?array
    {
        $rank = $this->redis->zRevRank($this->key, $member);
        if ($rank === false) {
            return null;
        }
        return ['rank' => $rank + 1, 'score' => (int) $this->redis->zScore($this->key, $member)];
    }
}

5. Ties and tie-breaking

Without explicit handling, Redis sorts ties in a leaderboard by the lexicographic order of the member string, which for player names looks essentially arbitrary and confuses users when the order of two players with an identical score changes unpredictably from update to update. A robust solution encodes a secondary criterion directly into the score, usually the point in time the score was reached, so that players with the same primary score are ordered by achievement time, typically with the earlier achievement on top.

The technical implementation uses floating point scores: the integer primary score forms the digits before the decimal point, an inverted, normalized timestamp forms the digits after it. For example: a score of 15670 reached at timestamp 1732000000 becomes 15670.0000001732000000 inverted, so an earlier timestamp gets a smaller fraction and thus a higher effective rank under ZREVRANGE. This technique avoids any extra data structures for tie-breaking entirely and keeps the leaderboard on a single sorted set operation.

Tie-breaking approach Complexity Advantage Drawback
No handling Trivial No extra effort Unpredictable order
Timestamp encoded in score Low A single sorted set access Limited score precision
Separate sorted set for tiebreak High Full precision for both criteria Two structures to keep in sync

6. Paginating large leaderboards

For display beyond the top-10, a leaderboard needs to be paginable without re-transmitting all previous entries on every page. ZREVRANGE leaderboard:global 0 9 WITHSCORES returns page one, ZREVRANGE leaderboard:global 10 19 WITHSCORES returns page two, with start and end index computed directly from the page number and page size. This offset based pagination is sufficient for most leaderboards, since users rarely browse through hundreds of pages.

For very large rankings with frequent score changes, offset pagination can lead to slightly inconsistent results when the ranking shifts between two page loads and a player appears twice or gets skipped. For the vast majority of leaderboard applications, this rare inconsistency is acceptable, since a ranking is inherently a snapshot of a constantly changing state, and absolute consistency between two requests is not a realistic goal.


# Pagination: page_size = 10, page = 3 (zero-indexed)
# start = page * page_size, stop = start + page_size - 1
redis-cli> ZREVRANGE leaderboard:global 20 29 WITHSCORES

# Total pages for UI pagination controls
redis-cli> ZCARD leaderboard:global
(integer) 48213
# total_pages = ceil(48213 / 10) = 4822

7. Neighborhood queries around a player

Users are usually less interested in the absolute top of the world than in their own position and the players directly above and below them. This neighborhood query combines ZREVRANK, to determine one's own rank, with ZREVRANGE for a computed range around that rank. For a player at rank 4523 with a desired neighborhood of five spots on each side, ZREVRANGE leaderboard:global 4517 4527 WITHSCORES is called, showing the player centered in their local neighborhood.

This pattern is what actually makes a leaderboard relevant to end users, because "rank 4523 of 48213" alone creates little motivation, while "three more spots to the next rank" provides concrete, actionable information. The combination of two Redis calls, ZREVRANK followed by ZREVRANGE, stays at constant practical response time regardless of the leaderboard's size, because both operations scale logarithmically, respectively linearly in the small result set.


# Neighborhood query: rank of player:4523, then 5 places above and below
redis-cli> ZREVRANK leaderboard:global "player:4523"
(integer) 4522

redis-cli> ZREVRANGE leaderboard:global 4517 4527 WITHSCORES
 1) "player:4501"
 2) "89210"
 3) "player:4502"
 4) "89180"
 ...
11) "player:4523"
12) "88760"

8. Time-based leaderboards with rotation

Many applications need not just one global leaderboard, but also daily and weekly rankings that reset regularly. The simplest approach creates its own key per period, for example leaderboard:daily:2026-07-23, and writes to both the global and the period-specific sorted set in parallel on every score event. For automatic cleanup, the period-specific key gets a TTL clearly longer than the period itself, so late evaluations still work, but old rankings eventually disappear automatically.

For weekly rankings, the ISO week key is a good fit, for example leaderboard:weekly:2026-W30, which avoids calendar calculations in the application while staying unique and sortable. Maintaining a leaderboard system across multiple periods in parallel increases the write load per event by the number of active periods, but even with three or four parallel rankings, for example daily, weekly, monthly and global, this stays unproblematic in practice, since each ZADD operation takes only a few microseconds even with millions of members.


-- update_score.lua
-- Writes into both the global and the daily rotating leaderboard atomically
-- KEYS[1] = global key, KEYS[2] = daily key, ARGV[1] = member, ARGV[2] = points
redis.call('ZINCRBY', KEYS[1], ARGV[2], ARGV[1])
redis.call('ZINCRBY', KEYS[2], ARGV[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], 172800) -- keep daily board for 2 days
return redis.call('ZSCORE', KEYS[1], ARGV[1])

9. Scaling and memory footprint

A sorted set with one million members occupies, depending on the length of the member strings, typically tens to a few hundred megabytes in Redis, which fits comfortably into the memory of a single Redis node for most leaderboards. For extremely large rankings with tens of millions of players, for example a global mobile game, it is worth reducing the member string to a compact numeric player ID instead of a long display name, keeping the display name separately in a hash or a database to minimize the sorted set's own memory footprint.

For horizontal scaling beyond a single Redis node, a leaderboard can be split by region or game mode into several sorted sets that can live on different shards of a Redis cluster. A globally aggregated leaderboard across all regions then either requires a periodic merge procedure with ZUNIONSTORE into a separate sorted set, or a deliberate decision to skip a single global leaderboard in favor of several regional rankings, which is often the more user-friendly choice anyway at very large player counts.

10. Summary

Leaderboards with Sorted Sets use a data structure designed for exactly this problem: ZADD and ZINCRBY for score updates, ZRANK and ZREVRANK for rank queries, ZREVRANGE with WITHSCORES for top-N lists and pagination, all in logarithmic time regardless of the ranking's size. Ties can be elegantly resolved by encoding a secondary criterion directly into the score, without needing any additional data structures.

Neighborhood queries around one's own position make a leaderboard motivating for end users, while time-based variants with dedicated keys per period enable daily and weekly rankings alongside the global leaderboard. At very large player counts, compact member design and splitting by region or game mode pay off for horizontal scaling.

Leaderboards with Sorted Sets, the essentials at a glance

Base operations

ZADD and ZINCRBY for scores, ZRANK/ZREVRANK and ZREVRANGE for ranking, all in O(log n).

Tie-breaking

Encode a secondary criterion like a timestamp directly into the score, no separate data model needed.

Pagination

Offset based with ZREVRANGE, neighborhood queries via ZREVRANK plus a surrounding range.

Time-based rankings

Dedicated key per period with TTL, parallel writes into the global and periodic set.

11. FAQ: Leaderboards with Sorted Sets

1Why Sorted Sets instead of SQL?
A skip list implementation gives O(log n) for insertion, deletion and rank queries.
2How do I update a score efficiently?
ZINCRBY increases the score atomically without reading the current value first.
3How do I resolve ties?
Encode a secondary criterion like a timestamp directly into the score.
4How do I paginate efficiently?
ZREVRANGE with a computed start and end index per page.
5How do I show a player's neighborhood?
ZREVRANK determines the rank, ZREVRANGE returns the surrounding range.
6How do time-based rankings work?
Dedicated key per period with TTL for automatic cleanup.
7How much memory for a million players?
Typically tens to a few hundred megabytes depending on member length.
8How does this scale across nodes?
Split by region or mode, optional merge via ZUNIONSTORE.
9ZREVRANGE or ZRANGE REV?
Functionally identical, ZRANGE REV is the newer unified syntax.
10Store names or IDs as members?
Compact IDs save memory, display names belong in a separate hash.