Mert Özen Further With Every Line
Database Redis — #06

Redis Sorted Sets: Leaderboards, Time-Windowed Rankings and Pagination

Mert Özen Nov 14, 2026 17 min 10 views
Redis Sorted Sets: Leaderboards, Time-Windowed Rankings and Pagination

A Redis sorted set keeps every member with a score and stays ordered at all times; rank and page queries on a leaderboard take microseconds even with a million members, but paging a live list by rank drifts between two requests. With real output: ZADD options, paging with ZRANGE, the week's bestsellers from daily sets with ZUNIONSTORE and Redis 8.8's COUNT, the order of equal scores, the 2^53 precision limit, and sliding windows that use a timestamp as the score.

Version note: The examples in this post were run with Redis 8.10.3 (redis:8), Valkey 9.1.3, .NET 10 and StackExchange.Redis 3.4.35.

Guess First

The home page of Bookstore, the online bookstore used as the shared example across this site's series, has a "today's bestsellers" list that customers page through. The list is read from a Redis sorted set: each book is a member and the number of copies sold is its score. There are six books, three per page. A customer opens the first page; at that moment book-5 sells 50 copies in one bulk order; the customer moves on to the second page:

RedisValue[] page1 = await db.SortedSetRangeByRankAsync(source, 0, 2, Order.Descending);
await db.SortedSetIncrementAsync(live, "book-5", 50);                                         // a sale between the two requests
RedisValue[] page2 = await db.SortedSetRangeByRankAsync(source, 3, 5, Order.Descending);

The scores start at 60 for book-1 and go down by ten; book-5 has 20. Which books does the customer see across the two pages? What changes if you run the same flow against a copy of the list taken at that moment (a snapshot)?

dotnet run --project src/Bookstore.RedisLab -- page-drift
live      page 1: book-1, book-2, book-3  page 2: book-3, book-4, book-6
snapshot  page 1: book-1, book-2, book-3  page 2: book-4, book-5, book-6

On the live list book-3 appeared twice and book-5 never appeared. It jumped to first place with a score of 70, but the first page had already been read. Paging by rank drifts when the order changes between two requests. With the snapshot the list stayed fixed, so the two pages showed all six books exactly once. This post covers sorted sets through questions like this one: the score and member model, the options of ZADD, ordering and paging, a rolling 7-day bestseller list built by combining daily lists, the order of equal scores, the precision limit of scores, and time windows that use a timestamp as the score.

Score and Member: the Sorted Set Model

A sorted set is a collection of unique strings, like the set from post 05; the difference is that every member carries a score (a 64-bit floating-point number) and the set is kept ordered by that score at all times. When you change a member's score, Redis moves it to the right place immediately; nothing is sorted on read, the order is already there:

127.0.0.1:6379> ZADD bookstore:bestsellers:20261101 12 9780441013593 7 9780451524935 7 9780141439518 3 9780547928227
(integer) 4
127.0.0.1:6379> ZINCRBY bookstore:bestsellers:20261101 2 9780547928227
"5"
127.0.0.1:6379> ZRANGE bookstore:bestsellers:20261101 0 2 REV WITHSCORES
1) "9780441013593"
2) "12"
3) "9780451524935"
4) "7"
5) "9780141439518"
6) "7"
127.0.0.1:6379> ZRANK bookstore:bestsellers:20261101 9780441013593 WITHSCORE
1) (integer) 3
2) "12"
127.0.0.1:6379> ZREVRANK bookstore:bestsellers:20261101 9780441013593
(integer) 0
127.0.0.1:6379> ZSCORE bookstore:bestsellers:20261101 9780141439518
"7"

Books are members by their ISBN and the copies sold are their scores. ZINCRBY increments the score and starts from 0 if the member does not exist; recording a sale is one command, as atomic as INCR in post 03. ZRANK gives the position in ascending order (starting at 0), ZREVRANK in descending order. A leaderboard usually wants the second one: Dune (9780441013593) is at position 0, the top. Since Redis 7.2 the WITHSCORE option returns the rank and the score in one go. The redis-cli commands in this post are in the snapshot under lab/scripts/; you can run them all in order with docker compose -f lab/compose.yaml exec -T redis redis-cli < lab/scripts/06-zset.txt.

One detail in the output deserves attention: 9780451524935 and 9780141439518 have the same score (7). Redis orders members with equal scores by comparing their bytes (lexicographic order); with REV that order is reversed as well, which is why the larger 9780451524935 came first. The result is deterministic, the same every time; but it follows the member's text, not a business rule. We will come back to this in Bookstore's weekly list below.

Inside, a sorted set is held in two structures: a hash table for O(1) access from member to score, and a skip list for ordered traversal (a linked list whose nodes have forward links on several levels, giving O(log N) search). Because the skip list also records how many elements each link skips, access by rank is O(log N) as well. Small sorted sets, like the hashes in post 04, live in a single listpack. The limits are zset-max-listpack-entries (128) and zset-max-listpack-value (64 bytes); the defaults are the same on Redis 8.10.3 and Valkey 9.1.3.

The Options of ZADD

By default ZADD adds a member if it is missing and updates its score if it exists. The options narrow that down:

127.0.0.1:6379> ZADD bookstore:bestsellers:20261101 NX 99 9780441013593
(integer) 0
127.0.0.1:6379> ZADD bookstore:bestsellers:20261101 GT CH 10 9780441013593 15 9780451524935
(integer) 1
127.0.0.1:6379> ZRANGE bookstore:bestsellers:20261101 0 -1 REV WITHSCORES
1) "9780451524935"
2) "15"
3) "9780441013593"
4) "12"
5) "9780141439518"
6) "7"
7) "9780547928227"
8) "5"

NX only adds new members; Dune already existed, so 99 was not written. XX is the opposite: it only updates existing members and never adds. GT updates a score only if the new value is greater, LT only if it is smaller. Dune's 12 was not overwritten by 10, while 9780451524935 went from 7 to 15. Keep in mind that GT and LT do not stop new members from being added; they only restrict updates to existing ones. CH changes the return value from the number of added members to the number of changed members; here it is 1, because only one score changed. GT is handy for "best score" tables: you keep a customer's best score in a campaign game with one command, without reading and comparing first.

In StackExchange.Redis these map to the SortedSetWhen parameter of SortedSetAddAsync (NotExists, Exists, GreaterThan, LessThan); ZINCRBY is SortedSetIncrementAsync.

Ordering and Paging with ZRANGE

Since Redis 6.2 a single ZRANGE command reads ranges by rank, by score (BYSCORE) and by member text (BYLEX); REV reverses the order and LIMIT offset count cuts the result:

127.0.0.1:6379> ZRANGE bookstore:bestsellers:20261101 +inf 6 BYSCORE REV LIMIT 0 2 WITHSCORES
1) "9780451524935"
2) "15"
3) "9780441013593"
4) "12"
127.0.0.1:6379> ZCOUNT bookstore:bestsellers:20261101 6 +inf
(integer) 3
127.0.0.1:6379> OBJECT ENCODING bookstore:bestsellers:20261101
"listpack"

"Books that sold 6 or more copies today, best seller first, the first two" is one command. With REV the range is written in reverse too: first the upper bound (+inf), then the lower one (6). Putting ( in front of a bound excludes that value: (6 means "greater than 6". ZCOUNT returns the number of members in the same range without sending the list. The older ZREVRANGE, ZRANGEBYSCORE and ZREVRANGEBYSCORE commands are marked deprecated in the docs but still work. One way to see this: in StackExchange.Redis 3.4.35 a read by rank with Order.Descending shows up as cmdstat_zrevrange in the server's INFO commandstats, so the library still sends the old command for this call.

There are two ways to page. Paging by rank (ZRANGE key 30 39 REV) is simple and cheap: the skip list reaches a rank in O(log N), so the page at position 500,000 is fast too. But as you saw in Guess First, the pages drift if a book's position changes between two requests. Paging with a score cursor (continuing from the last score you saw: ZRANGE key (last-score -inf BYSCORE REV LIMIT 0 10) is less affected by new sales, but it trips over equal scores: if the last score was 7 and only two of five books with score 7 fit on the previous page, (7 skips the other three. Getting around that means keeping the score and member pair as the cursor and filtering the equal-score members in the application. A large LIMIT offset with BYSCORE is costly on top of that: Redis has to walk over offset elements. For Bookstore we chose a third way: paging by rank over a snapshot that does not change. The order stays fixed for the snapshot's lifetime, so pages do not drift.

Combining Days: ZUNIONSTORE, WEIGHTS and COUNT

"Bestsellers of the week" needs the sales of seven days added up. Keeping one sorted set per day and combining them on the server is much simpler than trying to clean up a single weekly set every day: the oldest day is removed by its own TTL and the window moves on by itself. A small example with three days:

127.0.0.1:6379> ZADD bookstore:bestsellers:20261102 40 9780441013593 5 9780451524935
(integer) 2
127.0.0.1:6379> ZADD bookstore:bestsellers:20261103 6 9780451524935 4 9780141439518
(integer) 2
127.0.0.1:6379> ZADD bookstore:bestsellers:20261104 7 9780451524935 9 9780141439518
(integer) 2
127.0.0.1:6379> ZUNIONSTORE bookstore:bestsellers:3days 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104
(integer) 3
127.0.0.1:6379> ZRANGE bookstore:bestsellers:3days 0 -1 REV WITHSCORES
1) "9780441013593"
2) "40"
3) "9780451524935"
4) "18"
5) "9780141439518"
6) "13"
127.0.0.1:6379> ZUNION 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104 AGGREGATE COUNT WITHSCORES
1) "9780441013593"
2) "1"
3) "9780141439518"
4) "2"
5) "9780451524935"
6) "3"
127.0.0.1:6379> ZUNION 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104 WEIGHTS 1 2 3 AGGREGATE COUNT WITHSCORES
1) "9780441013593"
2) "1"
3) "9780141439518"
4) "5"
5) "9780451524935"
6) "6"
127.0.0.1:6379> ZINTER 3 bookstore:bestsellers:20261102 bookstore:bestsellers:20261103 bookstore:bestsellers:20261104 AGGREGATE MIN WITHSCORES
1) "9780451524935"
2) "5"
127.0.0.1:6379> ZRANGESTORE bookstore:bestsellers:3days:top2 bookstore:bestsellers:3days 0 1 REV
(integer) 2

ZUNIONSTORE adds the scores up by default (AGGREGATE SUM); MIN and MAX take the smallest or the largest. Redis 8.8 added COUNT to these: the scores are ignored entirely and each member's score becomes the number of sets it appears in. "On how many of the last seven days did this book sell?" is one command with it. Combined with WEIGHTS, each set contributes its own weight: 9780141439518 is in the second and third day, so the result is 2 + 3 = 5. ZINTER returns only the members present in every set; with AGGREGATE MIN the answer is "the book that sold on all three days, and sold 5 copies on its worst day". ZRANGESTORE copies a range into a new key; that is the snapshot from Guess First.

There is a small difference between the official docs and the measurement here. The ZUNIONSTORE doc page and the server's own COMMAND DOCS output both list the type of the WEIGHTS argument as "integer". But when I tried WEIGHTS 0.25 0.5 1, Redis 8.10.3 returned no error and produced fractional scores (for 9780451524935: 5 × 0.25 + 6 × 0.5 + 7 × 1 = 11.25). The matching StackExchange.Redis parameter is a double[] anyway. Fractional weights work; the "integer" in the docs does not reflect the actual behavior.

On Valkey 9.1.3 both commands with AGGREGATE COUNT returned ERR syntax error; SUM, MIN, MAX and WEIGHTS gave the same results. On Valkey, or on a Redis older than 8.8, you have to compute "days with sales" with separate sets whose scores are already 1, or in the application.

With more realistic data I put three different combinations side by side: 2,000 books, seven days, 20 books that sell steadily, and Dune selling 300 copies on a single campaign day six days ago. In StackExchange.Redis the combinations are done with SortedSetCombineAndStoreAsync:

await db.SortedSetCombineAndStoreAsync(SetOperation.Union, week, days);                          // ZUNIONSTORE ... (SUM)
await db.SortedSetCombineAndStoreAsync(SetOperation.Union, weekDays, days, null, Aggregate.Count); // AGGREGATE COUNT
double[] weights = [.. Enumerable.Range(1, 7).Select(i => i / 7.0)];                               // yesterday counts more
await db.SortedSetCombineAndStoreAsync(SetOperation.Union, trending, days, weights);
dotnet run --project src/Bookstore.RedisLab -- bestsellers
SUM      (copies in 7 days)
  1. 9780441013593  score=  300.0  days=1
  2. 9780000000002  score=  194.0  days=7
  3. 9780000000015  score=  186.0  days=7
  4. 9780000000011  score=  172.0  days=7
  5. 9780000000016  score=  162.0  days=7
weighted (recent days count more)
  1. 9780000000002  score=  113.9  days=7
  2. 9780000000015  score=  108.1  days=7
  3. 9780000000011  score=  106.3  days=7
  4. 9780000000001  score=  102.9  days=7
  5. 9780000000003  score=   94.7  days=7
books sold on all 7 days: 1,100 of 2,001

In the plain sum Dune is far ahead, even though it sold on only one day. Give the days weights rising from 1/7 to 7/7 and the campaign from six days ago loses its effect; the list turns into "what is selling now". Which one is right is a business decision; what Redis does is produce both lists from the daily sets with a single command each. The days column was read in one call from the set built with COUNT, using ZMSCORE (SortedSetScoresAsync), and ZCOUNT key 7 7 counted the 1,100 books that sold on all seven days.

Bookstore: Bestsellers of the Week

In Bookstore.Api every order, once it has passed the idempotency check from post 03, writes the sale to two places: the day's bestseller set and the recent orders set (the topic of the next section):

// Daily bestsellers (ZINCRBY) and the time-ordered recent orders (timestamp as score)
private static async Task RecordSaleAsync(IDatabase db, CreateOrderRequest request, long orderId, DateTimeOffset now)
{
    RedisKey daily = RedisKeys.Bestsellers(DateOnly.FromDateTime(now.UtcDateTime));
    await Task.WhenAll(
        db.SortedSetIncrementAsync(daily, request.Isbn, request.Quantity),                       // ZINCRBY
        db.KeyExpireAsync(daily, TimeSpan.FromDays(8), ExpireWhen.HasNoExpiry),                  // EXPIRE ... NX
        db.SortedSetAddAsync(RedisKeys.RecentOrders, orderId, now.ToUnixTimeMilliseconds()));    // ZADD
}

The daily key bookstore:bestsellers:{yyyyMMdd} gets an 8-day TTL on its first write, like the daily view counter in post 03; we leave one day of slack for the seven-day window. The weekly list is not recomputed on every request; the first request builds a snapshot that lives for 10 minutes. The snapshot's score combines two pieces of information: copies sold × 10 + the number of days with sales. We will see why in a moment:

// score = copies sold * 10 + days with sales (0-7): equal sales are ordered by how steadily the book sold
private static async Task BuildWeekAsync(IDatabase db, DateOnly today, RedisKey week)
{
    RedisKey[] days = [.. Enumerable.Range(0, 7).Select(i => RedisKeys.Bestsellers(today.AddDays(-i)))];
    string buildId = Guid.NewGuid().ToString("N");                 // own temporary keys per build
    RedisKey sold = RedisKeys.BestsellersWeekPart(today, "sold", buildId);
    RedisKey daysWithSales = RedisKeys.BestsellersWeekPart(today, "days", buildId);

    await Task.WhenAll(
        db.SortedSetCombineAndStoreAsync(SetOperation.Union, sold, days),                                 // ZUNIONSTORE (SUM)
        db.SortedSetCombineAndStoreAsync(SetOperation.Union, daysWithSales, days, null, Aggregate.Count)); // AGGREGATE COUNT (8.8+)
    await db.SortedSetCombineAndStoreAsync(SetOperation.Union, week, [sold, daysWithSales], [10, 1]);     // WEIGHTS 10 1
    await Task.WhenAll(
        db.KeyDeleteAsync([sold, daysWithSales]),
        db.KeyExpireAsync(week, SnapshotLifetime));
}

The endpoint builds the snapshot if it is missing, then reads a page by rank and splits the score into its two parts:

app.MapGet("/bestsellers/week", async (IConnectionMultiplexer redis, TimeProvider clock, int page = 1, int pageSize = 10) =>
{
    IDatabase db = redis.GetDatabase();
    DateOnly today = DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime);
    RedisKey week = RedisKeys.BestsellersWeek(today);

    if (!await db.KeyExistsAsync(week))
    {
        await BuildWeekAsync(db, today, week);
    }

    long start = (page - 1) * pageSize;
    SortedSetEntry[] entries = await db.SortedSetRangeByRankWithScoresAsync(week, start, start + pageSize - 1, Order.Descending);
    return Results.Ok(entries.Select((e, i) => new
    {
        rank = start + i + 1,
        isbn = e.Element.ToString(),
        sold = Math.Floor(e.Score / 10),
        daysWithSales = e.Score % 10
    }));
});

To try it, I added a command that writes the sales of the previous six days relative to today's date, then sent five orders to the API (with the Idempotency-Key header, as in post 03):

dotnet run --project src/Bookstore.RedisLab -- seed-week
curl -X POST http://localhost:5080/orders -H "Content-Type: application/json" -H "Idempotency-Key: k-06-1" -d '{"customerId":7,"isbn":"9780441013593","quantity":2}'
{"orderId":1,"replayed":false}
# the same for 9780451524935 x1, 9780141439518 x3, 9780441013593 x1, 9780547928227 x2

curl "http://localhost:5080/bestsellers/today?page=1&pageSize=3"
[{"rank":1,"isbn":"9780441013593","sold":3},{"rank":2,"isbn":"9780141439518","sold":3},{"rank":3,"isbn":"9780547928227","sold":2}]
curl "http://localhost:5080/bestsellers/week?page=1&pageSize=3"
[{"rank":1,"isbn":"9780441013593","sold":27,"daysWithSales":7},{"rank":2,"isbn":"9780451524935","sold":27,"daysWithSales":6},{"rank":3,"isbn":"9780547928227","sold":15,"daysWithSales":3}]
curl "http://localhost:5080/bestsellers/week?page=2&pageSize=3"
[{"rank":4,"isbn":"9780141439518","sold":5,"daysWithSales":2},{"rank":5,"isbn":"9780060850524","sold":3,"daysWithSales":1},{"rank":6,"isbn":"9780553293357","sold":2,"daysWithSales":1}]
docker compose -f lab/compose.yaml exec redis redis-cli TTL bookstore:bestsellers:week:20261114
(integer) 600

Two books are tied at 27 copies on the weekly list. What would happen if the score were only the copies sold? I combined the seven days into tmp:sold (SUM) and tmp:days (COUNT), then put both into one set with the combined score (the full commands are in lab/scripts/06-ties.txt in the snapshot; change the dates to your own day):

127.0.0.1:6379> ZRANGE tmp:sold 0 2 REV WITHSCORES
1) "9780451524935"
2) "27"
3) "9780441013593"
4) "27"
5) "9780547928227"
6) "15"
127.0.0.1:6379> ZMSCORE tmp:days 9780451524935 9780441013593
1) "6"
2) "7"
127.0.0.1:6379> ZUNIONSTORE tmp:ranked 2 tmp:sold tmp:days WEIGHTS 10 1
(integer) 7
127.0.0.1:6379> ZRANGE tmp:ranked 0 2 REV WITHSCORES
1) "9780441013593"
2) "277"
3) "9780451524935"
4) "276"
5) "9780547928227"
6) "153"

Sorted by copies alone, the tied book with the "larger" ISBN is in front: there is no rule, only chance. With the combined score (277 against 276), Dune, which sold on all seven days, moves ahead. If which book comes first in a tie is a business rule, do not leave it to Redis's lexicographic order; encode the rule into the score. Because this technique keeps the score as a single number, it has a limit.

Score Precision: the 2^53 Limit

The score is an IEEE 754 double. According to the ZADD docs, this format holds integers exactly only between -(2^53) and +(2^53); values beyond that are stored approximately. Let's see what happens when the combined score gets a bit larger: copies sold × 10^13 + the Unix millisecond of the first sale. The idea is to break ties by "started selling earlier":

// Composite score: copies sold * 10^13 + first-sale time in Unix milliseconds
long firstSaleA = 1_763_000_000_000, firstSaleB = 1_763_000_000_001;
foreach (long sold in new long[] { 12, 900, 901 })
{
    long a = sold * 10_000_000_000_000 + firstSaleA;
    long b = sold * 10_000_000_000_000 + firstSaleB;
    await db.SortedSetAddAsync(key, [new SortedSetEntry("book-a", a), new SortedSetEntry("book-b", b)]);
    double? storedA = await db.SortedSetScoreAsync(key, "book-a");
    double? storedB = await db.SortedSetScoreAsync(key, "book-b");
    Console.WriteLine($"sold={sold,3}  sent a={a} b={b}  stored a={storedA:F0} b={storedB:F0}  equal={storedA == storedB}");
}
sold= 12  sent a=121763000000000 b=121763000000001  stored a=121763000000000 b=121763000000001  equal=False
sold=900  sent a=9001763000000000 b=9001763000000001  stored a=9001763000000000 b=9001763000000001  equal=False
sold=901  sent a=9011763000000000 b=9011763000000001  stored a=9011763000000000 b=9011763000000000  equal=True
2^53 = 9007199254740992

Up to 900 copies everything is correct. At 901 the score passes 2^53; in that range a double can only represent even numbers, so ...001 was rounded to ...000 and two different books got the same score. No error, no warning; the tie-break silently disappeared. The problem is not even in Redis: the information is lost the moment the C# long is converted to a double. If you build a composite score, multiply the largest values of both parts and make sure the result stays below 2^53 (about 9 × 10^15). Bookstore's × 10 + days is safe up to billions of sales a week. If you need a large value like a timestamp, reduce it to seconds, count from a fixed start date, or do the tie-break in the member instead of the score.

Timestamps as Scores: Sliding Windows

The second common face of the sorted set is time: the score is a Unix millisecond and the member is the event's identity. Bookstore's operations screen answers "how many orders came in during the last 5 minutes?" this way. RecordSaleAsync adds every order to bookstore:orders:recent with its timestamp; the endpoint counts the ones since the start of the window and removes entries older than an hour:

// Orders in the last N minutes: timestamp as score, old entries trimmed on every read
app.MapGet("/orders/recent", async (IConnectionMultiplexer redis, TimeProvider clock, int minutes = 5) =>
{
    IDatabase db = redis.GetDatabase();
    DateTimeOffset now = clock.GetUtcNow();
    long windowStart = now.AddMinutes(-minutes).ToUnixTimeMilliseconds();
    long keepFrom = now.Add(-RecentOrdersKept).ToUnixTimeMilliseconds();

    Task<long> trimmed = db.SortedSetRemoveRangeByScoreAsync(RedisKeys.RecentOrders,
        double.NegativeInfinity, keepFrom, Exclude.Stop);                                  // ZREMRANGEBYSCORE key -inf (keepFrom
    Task<long> count = db.SortedSetLengthAsync(RedisKeys.RecentOrders, windowStart);       // ZCOUNT key windowStart +inf
    await Task.WhenAll(trimmed, count);
    return Results.Ok(new { minutes, orders = count.Result });
});
curl "http://localhost:5080/orders/recent?minutes=5"
{"minutes":5,"orders":5}

With busier data: 600 orders spread over the last 10 minutes, one per second.

dotnet run --project src/Bookstore.RedisLab -- recent-orders
orders in the last 5 minutes: 300, removed older entries: 300, left: 300
newest: order-5599, 1000 ms ago

Two details matter. Members must be unique: add the same member a second time and no new event is added, the old one's score is updated instead. The order number is a natural identity here; for events without one (a request, say) you append a random suffix to the timestamp. Second, the set does not shrink by itself; unless you remove entries with ZREMRANGEBYSCORE, it grows forever. The same structure is the basis of a sliding window rate limiter, but its design, memory cost and alternatives are in "Designing Rate Limiting: Token Bucket, Sliding Window and a Distributed Rate Limiter" in the System Design series, and the ASP.NET Core side is in "Rate Limiting in Depth" in the ASP.NET Core Security series; I will not repeat them here. The add, count and remove steps are not one atomic step; the atomic version is the topic of post 08.

In Production

I measured memory and speed: two small sets with 128 and 129 members, and one with a million members:

dotnet run --project src/Bookstore.RedisLab -- zset-encoding
      128 members:        1,593 bytes, encoding=listpack
      129 members:       11,681 bytes, encoding=skiplist
1,000,000 members:   77,730,753 bytes, encoding=skiplist
ZRANGE 0 -1: 1,000,000 members in 183 ms (client side), page at rank 500,000: 50 entries
cmdstat_zrevrange: calls=1,usec=17,usec_per_call=17.00,rejected_calls=0,failed_calls=0
cmdstat_zrange: calls=1,usec=18665,usec_per_call=18665.00,rejected_calls=0,failed_calls=0,slowlog_count=1,slowlog_time_ms_sum=18.66,slowlog_time_ms_max=18.66
cmdstat_zrevrank: calls=10000,usec=37033,usec_per_call=3.70,rejected_calls=0,failed_calls=0
slowlog: ZRANGE bookstore:bestsellers:all-time 0 -1 18.7 ms

The 129th member turned the set from listpack into skiplist and memory grew 7.3 times. A million members take about 74 MB; around 78 bytes per member, most of it a skip list node and a hash table entry. ZREVRANK on a million members averaged 3.7 µs, and the 50-element page at position 500,000 took 17 µs; on a large leaderboard, "what is my rank?" and "give me this page" are cheap. ZRANGE 0 -1, on the other hand, held the server for 18.7 ms and landed in SLOWLOG; every other client waited during that time (post 01). Read page by page, and if you need to walk a large set, use ZSCAN (SortedSetScanAsync).

Do not forget the TTL on the daily sets; here it is 8 days. There is a reason why the temporary keys in the snapshot code are unique per build: if two requests arrive right when the snapshot expires, both start building. With shared temporary keys, one could finish and delete them while the other runs ZUNIONSTORE from those keys and overwrites the weekly list with an empty set. I tried it with 8 concurrent requests; with per-request keys the list came out right every time. Doing the whole thing in one atomic step (MULTI/EXEC or a Lua script) is the topic of post 08. The snapshot's lifetime is a design decision as well: the list does not change for 10 minutes; acceptable for a bestseller list, not for stock levels. The weekly list is only a computed result; its source is the daily sets, and those are not the order itself either. The order's real record is in the database.

Common Pitfalls

Paging by rank over a live list that changes often: in Guess First one book appeared twice and another never appeared. Use a snapshot or a score + member cursor.

Taking the order of equal-score members for a business rule: Redis breaks ties by member text. If you have a rule, encode it into the score.

Going past 2^53 in a composite score: after 901 copies two different values were rounded to the same score, with no error or warning.

ZRANGE 0 -1 on a large sorted set: a million members made the server wait for 18.7 ms. Use LIMIT, a rank range or ZSCAN.

Not removing old entries from a timestamp set, or adding the same member again: the first means unbounded growth, the second lost events.

Assuming Redis 8.8's AGGREGATE COUNT on Valkey or on an older managed Redis: the command is rejected with ERR syntax error.

A Small Experiment

★☆☆ Add a member longer than 64 bytes to bookstore:bestsellers:20261101 (a 70-character title, for example) and look at OBJECT ENCODING and MEMORY USAGE again. Does the encoding go back when you remove that member?

★★☆ Add a score-cursor version of the /bestsellers/today endpoint: the response returns the last book's score and ISBN as nextCursor, and the next request continues without skipping books with equal scores. Try it with data where five books are tied at a score of 7.

★★★ For "trending books", keep per-minute sorted sets (bookstore:trending:{yyyyMMddHHmm}) and combine the last 60 minutes with WEIGHTS so that recent minutes count more. How many milliseconds does a 60-key union take? How does keeping an hourly subtotal change that?

In the next post we move on to places where an approximate answer is good enough: daily active users with a bitmap, unique counting in 12 KB with HyperLogLog (with a memory comparison against the visitor sets from post 05), Geo for the nearest store, and the probabilistic structures that became part of the core in Redis 8, such as Bloom, Cuckoo and Top-K.