Sorted Sets (ZSET)

intermediate redis sorted-sets leaderboard data-structures

A sorted set (ZSET) is the most powerful Redis data type. It’s a set of unique strings, where each member has a floating-point score, and the set is always kept ordered by score. Adds, removes, and rank lookups are O(log N).

Think of it like a database table with two columns — member and score — that’s always sorted by score and has a unique index on member. Except it runs in microseconds.

Internals: skiplist + hashmap

In simple language, Redis maintains two structures in parallel:

  • A skiplist for ordered traversal and range queries (O(log N))
  • A hashmap from member → score for O(1) score lookups

When we add a member, both structures update atomically.

ZSET Internal Layout
Skiplist (ordered)
alice → 3200
bob → 4500
carol → 5100
dave → 6900
range queries: O(log N)
Hashmap (member→score)
"alice" → 3200
"bob" → 4500
"carol" → 5100
"dave" → 6900
score lookup: O(1)

The basics

ZADD game:leaderboard 4500 "alice" 3200 "bob" 5100 "carol" 6900 "dave"
ZADD game:leaderboard 5500 "alice"     # update alice's score

ZSCORE game:leaderboard "alice"
# "5500"

ZRANK game:leaderboard "bob"
# (integer) 0       — lowest score, rank 0
ZREVRANK game:leaderboard "bob"
# (integer) 3       — from the top, rank 3

ZCARD game:leaderboard
# (integer) 4

Leaderboards (the canonical example)

# Top 10 players, highest first
ZREVRANGE game:leaderboard 0 9 WITHSCORES

# Players 100..119 (pagination)
ZREVRANGE game:leaderboard 100 119 WITHSCORES

# Bump alice by 100 points
ZINCRBY game:leaderboard 100 "alice"

Real-world numbers: a leaderboard with 10 million players still answers “top 100” and “what’s my rank” in well under a millisecond.

Range by score

# Players with score between 4000 and 5000, inclusive
ZRANGEBYSCORE game:leaderboard 4000 5000 WITHSCORES

# Strict bounds: (4000, 5000]
ZRANGEBYSCORE game:leaderboard "(4000" 5000

# All players above 5000
ZRANGEBYSCORE game:leaderboard 5000 +inf

Time-series / event log

Use a timestamp as the score and we get a sorted log of events:

ZADD events:user:42 1716720000 "login"
ZADD events:user:42 1716720130 "click:cart"
ZADD events:user:42 1716720180 "purchase"

# Events in the last hour
ZRANGEBYSCORE events:user:42 1716716400 +inf

Sliding-window rate limiter

This is one of the cleanest patterns in Redis. We use the timestamp as both the score and the member (with a unique suffix), then trim everything outside the window.

# At request time
ZADD rate:user:42 1716720000123 "1716720000123-req1"
ZREMRANGEBYSCORE rate:user:42 0 1716719940123    # drop entries older than 60s
ZCARD rate:user:42                                # count remaining
# If > 100, reject.
EXPIRE rate:user:42 120

This gives a true sliding window (not the buggy fixed-window pattern), and the whole thing is O(log N + M) where M is the items removed.

Lexicographic range (same score)

When all scores are equal, ZSET sorts members lexicographically. Use it for autocomplete or ordered string ranges:

ZADD autocomplete 0 "apple" 0 "appetizer" 0 "application" 0 "banana"
ZRANGEBYLEX autocomplete "[app" "[app\xff"
# 1) "appetizer"
# 2) "apple"
# 3) "application"

Practical client snippet

// Track top trending products by view count this hour
await redis.zincrby("trending:hour", 1, `product:${productId}`);
await redis.expire("trending:hour", 3600);

// Top 10 trending right now
const top = await redis.zrevrange("trending:hour", 0, 9, "WITHSCORES");

When ZSET is wrong

If we don’t need ordering, a set is cheaper. If we just need a queue, a list is cheaper. If we need a Kafka-style log with replay, use streams. ZSET’s superpower is ordered by score with fast updates — use it when that exact shape matters.