Redis sorted set: leaderboard, priority queue and sliding window

1. Overview

Sorted set (ZSET) is Redis’s most versatile data structure. Each element (member) is associated with a score, and Redis always keeps them ordered by score. This article uses ZSET to solve three common problems: leaderboards, priority/delay queues, and sliding window rate limiters. All written in Java 21 with Spring Boot 3.x. After reading, you’ll know how to choose scores for each problem and avoid common race condition bugs.

2. How sorted sets work

A ZSET has the following properties:

  • Member is unique. ZADD on an existing member only updates its score.
  • Score is a real number (double). Members with the same score are sorted by lexicographic order of the member.
  • Add, remove, update, and get rank all have O(log N) complexity. Getting a range of elements costs O(log N + M), where M is the number of elements returned.

You can quickly test it with redis-cli:

ZADD scores 120 alice 95 bob 150 carol
ZREVRANGE scores 0 1 WITHSCORES
ZREVRANK scores bob
1) "carol"
2) "150"
3) "alice"
4) "120"
(integer) 2

ZREVRANGE returns elements in descending score order. ZREVRANK tells you bob’s position (counting from 0) in that order.

2.1. Setting up the project

Add Spring Data Redis dependency. The two Testcontainers dependencies are used for the test section in section 6:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Configure the connection:

spring.data.redis.host=localhost
spring.data.redis.port=6379

Spring Boot automatically creates a StringRedisTemplate bean. All ZSET operations go through opsForZSet().

3. Leaderboard

Problem: a game awards points to players, and you need to get the top N and a player’s rank. Here, score is the points, and member is the player’s ID.

public record PlayerScore(String player, double score) {}

@Service
public class LeaderboardService {

    private static final String KEY = "leaderboard:game:2048";
    private final ZSetOperations<String, String> zSet;

    public LeaderboardService(StringRedisTemplate redis) {
        this.zSet = redis.opsForZSet();
    }

    public double addPoints(String player, double points) {
        return zSet.incrementScore(KEY, player, points);
    }

    public List<PlayerScore> top(int n) {
        Set<ZSetOperations.TypedTuple<String>> tuples =
                zSet.reverseRangeWithScores(KEY, 0, n - 1);
        if (tuples == null) {
            return List.of();
        }
        return tuples.stream()
                .map(t -> new PlayerScore(t.getValue(), t.getScore()))
                .toList();
    }

    public Optional<Long> rankOf(String player) {
        Long rank = zSet.reverseRank(KEY, player);
        return Optional.ofNullable(rank).map(r -> r + 1);
    }
}

Explanation of each part:

  • incrementScore corresponds to the ZINCRBY command. This command is atomic. If the member doesn’t exist, Redis treats the initial score as 0. You don’t need to read the old score, add to it, and write it back, so there’s no race condition when multiple requests add points to the same player.
  • reverseRangeWithScores(KEY, 0, n - 1) corresponds to ZREVRANGE ... WITHSCORES. The end index is inclusive, so the top 10 is 0..9.
  • reverseRank returns null if the player isn’t in the leaderboard. We add 1 to make the rank start from 1 for user-friendliness.

Try it out:

leaderboard.addPoints("alice", 120);
leaderboard.addPoints("bob", 95);
leaderboard.addPoints("carol", 150);
leaderboard.addPoints("bob", 60);

System.out.println(leaderboard.top(3));
System.out.println(leaderboard.rankOf("alice"));
[PlayerScore[player=bob, score=155.0], PlayerScore[player=carol, score=150.0], PlayerScore[player=alice, score=120.0]]
Optional[3]

3.1. Handling tied scores

When two players have the same score, Redis sorts them lexicographically by member name. With ZREVRANGE, the lexicographic order is reversed, so zoe comes before anna. This is rarely what you want. If you want whoever scored first to rank higher, you can encode the timestamp into the fractional part of the score:

public double addPointsWithTieBreak(String player, long points) {
    long now = System.currentTimeMillis();
    double tieBreak = 1.0 - (now / 1e13);
    double score = points + tieBreak;
    zSet.add(KEY, player, score);
    return score;
}

Someone who scores earlier has a smaller now, so a larger tieBreak, and ranks higher. When displaying, you take Math.floor(score) to get the actual points.

Note: Score is double, which only precisely represents integers up to 2^53 (around 9×10^15). When combining multiple values into one score, you need to calculate beforehand to avoid losing precision. With the above approach, points should be under a few million. Also, this approach doesn’t work with incrementScore, because each addition accumulates the fractional part as well.

4. Priority queues and delayed queues

4.1. Priority queue

This time, score is the priority: smaller numbers are higher priority. To keep jobs with the same priority in FIFO order, we embed the timestamp into the score:

@Service
public class JobQueue {

    private static final String KEY = "queue:email-jobs";
    private final ZSetOperations<String, String> zSet;

    public JobQueue(StringRedisTemplate redis) {
        this.zSet = redis.opsForZSet();
    }

    public void enqueue(String jobId, int priority) {
        if (priority < 0 || priority > 9) {
            throw new IllegalArgumentException("priority must be 0..9");
        }
        double score = priority * 1e13 + System.currentTimeMillis();
        zSet.add(KEY, jobId, score);
    }

    public Optional<String> poll() {
        var tuple = zSet.popMin(KEY);
        return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
    }

    public Optional<String> take(Duration timeout) {
        var tuple = zSet.popMin(KEY, timeout.toSeconds(), TimeUnit.SECONDS);
        return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
    }
}

Key points:

  • priority * 1e13 + millis produces a value around 9.2×10^13, still well below the 2^53 threshold. A job with priority 0 always comes out before a job with priority 1, even if added later.
  • popMin corresponds to ZPOPMIN. This command gets and deletes the element in one atomic step. Two workers never receive the same job.
  • popMin(key, timeout, unit) corresponds to BZPOPMIN. This command blocks the connection until a job arrives or the timeout expires, so you avoid continuous polling that wastes CPU.

Note: BZPOPMIN holds a connection for the entire wait period. With Lettuce’s default shared connection, a blocking command will block other commands as well. You should enable connection pooling (add commons-pool2 and configure spring.data.redis.lettuce.pool.*) or use a separate connection factory for workers.

4.2. Delayed queue

Want to send a payment reminder email after 30 minutes? Use a score that is the execution time (epoch millis). Workers periodically fetch jobs with a score less than or equal to the current time.

The approach below is wrong: call rangeByScore then remove. Two workers could both read the same job before either deletes it. We put both steps in a Lua script so Redis executes them atomically:

@Service
public class DelayedJobQueue {

    private static final String KEY = "queue:delayed";
    private static final RedisScript<String> POP_DUE = new DefaultRedisScript<>("""
            local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
            if #jobs == 0 then return false end
            redis.call('ZREM', KEYS[1], jobs[1])
            return jobs[1]
            """, String.class);

    private final StringRedisTemplate redis;

    public DelayedJobQueue(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void schedule(String jobId, Instant runAt) {
        redis.opsForZSet().add(KEY, jobId, runAt.toEpochMilli());
    }

    public Optional<String> pollDue(Instant now) {
        String job = redis.execute(POP_DUE, List.of(KEY), String.valueOf(now.toEpochMilli()));
        return Optional.ofNullable(job);
    }
}

The script fetches up to one job that’s due and deletes it. In Lua, return false is converted to nil, so Java receives null. The worker could be a @Scheduled(fixedDelay = 500) that calls pollDue in a loop until empty.

Note: After popping, the job is gone from Redis. If the worker crashes mid-process, the job is lost. For at-least-once delivery, move the job to a “processing” ZSET with a deadline as the score, then delete it when done. If requirements are more complex, consider Redis Streams or a real message broker.

5. Sliding window rate limiter

Fixed window (counting requests per minute) has a boundary issue. For example: 100 requests at 00:59 and 100 requests at 01:00 both pass, meaning 200 requests in 2 seconds. Sliding window fixes this by storing the timestamp of each request in a ZSET:

  1. Remove requests older than now - window (ZREMRANGEBYSCORE).
  2. Count remaining requests (ZCARD).
  3. If not over the limit, add the current request with score now.

All three steps must run atomically, so we use Lua again:

@Service
public class SlidingWindowRateLimiter {

    private static final RedisScript<Long> SCRIPT = new DefaultRedisScript<>("""
            local key    = KEYS[1]
            local now    = tonumber(ARGV[1])
            local window = tonumber(ARGV[2])
            local limit  = tonumber(ARGV[3])
            redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
            if redis.call('ZCARD', key) < limit then
                redis.call('ZADD', key, now, ARGV[4])
                redis.call('PEXPIRE', key, window)
                return 1
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;
    private final Clock clock;

    public SlidingWindowRateLimiter(StringRedisTemplate redis, Clock clock) {
        this.redis = redis;
        this.clock = clock;
    }

    public boolean tryAcquire(String customerId, int limit, Duration window) {
        Long allowed = redis.execute(SCRIPT,
                List.of("rate:" + customerId),
                String.valueOf(clock.millis()),
                String.valueOf(window.toMillis()),
                String.valueOf(limit),
                UUID.randomUUID().toString());
        return Long.valueOf(1L).equals(allowed);
    }
}

Explanation:

  • Member is a random UUID. If you use a timestamp as the member, two requests in the same millisecond would overwrite each other and be counted as one.
  • PEXPIRE helps Redis clean up keys of inactive customers.
  • You should declare a Clock bean (for example, Clock.systemUTC()). Injecting Clock makes testing easier. However, if you have multiple application instances, their clocks should be synchronized, or you fetch the time in the script using redis.call('TIME').

Note: Each request consumes one element in the ZSET. With large limits, for example 100,000 requests/hour per customer, memory grows rapidly. In that case, use a sliding window counter (estimated from two fixed windows) or token bucket.

In cluster mode, a script only touches keys in the same hash slot. These scripts only use one key at a time, so there’s no issue.

6. Testing with Testcontainers

The test below runs with real Redis in Docker. @ServiceConnection automatically configures the host and port for Spring.

@SpringBootTest
@Testcontainers
class SortedSetFeaturesTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired LeaderboardService leaderboard;
    @Autowired JobQueue jobQueue;
    @Autowired SlidingWindowRateLimiter limiter;
    @Autowired StringRedisTemplate template;

    @BeforeEach
    void flush() {
        template.getConnectionFactory().getConnection().serverCommands().flushAll();
    }

    @Test
    void leaderboardOrdersByScoreDescending() {
        leaderboard.addPoints("alice", 120);
        leaderboard.addPoints("bob", 95);
        leaderboard.addPoints("bob", 60);

        assertThat(leaderboard.top(2))
                .extracting(PlayerScore::player)
                .containsExactly("bob", "alice");
        assertThat(leaderboard.rankOf("alice")).contains(2L);
        assertThat(leaderboard.rankOf("nobody")).isEmpty();
    }

    @Test
    void higherPriorityJobComesFirst() {
        jobQueue.enqueue("newsletter", 5);
        jobQueue.enqueue("password-reset", 0);

        assertThat(jobQueue.poll()).contains("password-reset");
        assertThat(jobQueue.poll()).contains("newsletter");
        assertThat(jobQueue.poll()).isEmpty();
    }

    @Test
    void rateLimiterRejectsAfterLimit() {
        Duration window = Duration.ofSeconds(10);

        assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
        assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
        assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
        assertThat(limiter.tryAcquire("c-42", 3, window)).isFalse();
        assertThat(limiter.tryAcquire("c-99", 3, window)).isTrue();
    }
}

The last test checks two things: the fourth request from c-42 is rejected, and the limit is calculated per customer. To test the sliding window “window,” inject an adjustable Clock (for example, a custom MutableClock class), advance the time by 10 seconds, then call tryAcquire again.

7. When not to use sorted set

  • Need durability like a message queue: ZSET doesn’t have acknowledgments, consumer groups, or retries. Redis Streams, RabbitMQ, or Kafka are better fits.
  • A single ZSET is too large: tens of millions of elements in one key cause a “big key” problem. Operations like DEL, slot migration, or replication become slow. You should partition keys by season, region, or day (leaderboard:2026-10).
  • Score needs absolute precision: for example, currency with many decimal places. double has rounding errors, so store in the smallest unit (dollars, cents) as an integer.
  • Approximate counting is enough: if you only need to know “roughly how many unique users,” HyperLogLog uses far less memory.

8. Conclusion

Sorted set solves many problems just by choosing the right score. For leaderboards, score is points and you use ZINCRBY, ZREVRANGE, ZREVRANK. For priority queues, score is priority combined with timestamp, using ZPOPMIN/BZPOPMIN. For delayed queues, score is the execution time. For rate limiting, score is the timestamp of each request.

Two things to remember. First, every sequence of “read then write” operations must be wrapped in an atomic command or Lua script. Second, always check the precision limits of double when combining multiple values into one score. Once you master these two points, you can confidently use ZSET in production systems.

redisspring-bootspring-data-redissorted-setjava