rembrembdocs

Redis leaderboard with Java (Lettuce)

Implement async and reactive Redis leaderboards in Java with Lettuce and sorted sets

This guide shows you how to implement Redis-backed leaderboards in Java with the Lettuce client library. It focuses on asynchronous and reactive APIs, which are the recommended Lettuce usage patterns in these docs.

Overview

Leaderboards are a classic Redis pattern. A sorted set stores each member together with a numeric score, and Redis maintains the ranking order automatically.

That gives you:

In this example, the leaderboard score data is stored in a sorted set called leaderboard:demo, and each user's metadata is stored in a hash such as leaderboard:demo:user:player-17.

How it works

The flow looks like this:

  1. Store each user ID in a sorted set with their score
  2. Store per-user metadata in a separate Redis hash keyed by user ID
  3. Fetch the highest-ranked users with a reverse range query
  4. Fetch users around a given rank by calculating a rank window
  5. Trim the leaderboard after updates so only the top configured entries remain

Separating rank data from metadata keeps leaderboard operations efficient while still letting the application render richer profile details.

Installation

Add the Lettuce dependency to your project:

Lettuce vs Jedis

In these docs, Jedis is the recommended client when you want straightforward synchronous Java examples. The Lettuce examples here focus on async and reactive APIs, where Lettuce is especially strong.

The Java leaderboard

The async implementation is provided by AsyncRedisLeaderboard (source):

import java.util.Map;

import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;

public class Main {
    public static void main(String[] args) {
        RedisClient redisClient = RedisClient.create("redis://localhost:6379");
        StatefulRedisConnection<String, String> connection = redisClient.connect();

        AsyncRedisLeaderboard board = new AsyncRedisLeaderboard(
                connection.async(),
                "leaderboard:demo",
                100
        );

        board.upsertUser(
                "player-1",
                1200,
                Map.of(
                        "name", "Ada",
                        "description", "Solves production incidents before breakfast."
                )
        ).thenCompose(entry -> board.incrementScore("player-1", 25, Map.of()))
         .thenCompose(entry -> board.getTop(5))
         .thenAccept(System.out::println)
         .join();

        connection.close();
        redisClient.shutdown();
    }
}

The reactive implementation is provided by ReactiveRedisLeaderboard (source):

import java.util.Map;

import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;

public class Main {
    public static void main(String[] args) {
        RedisClient redisClient = RedisClient.create("redis://localhost:6379");
        StatefulRedisConnection<String, String> connection = redisClient.connect();

        ReactiveRedisLeaderboard board = new ReactiveRedisLeaderboard(
                connection.reactive(),
                "leaderboard:demo",
                100
        );

        board.upsertUser(
                "player-1",
                1200,
                Map.of(
                        "name", "Ada",
                        "description", "Solves production incidents before breakfast."
                )
        ).then(board.getTop(5))
         .doOnNext(System.out::println)
         .block();

        connection.close();
        redisClient.shutdown();
    }
}

Data model

The implementation uses two Redis structures:

leaderboard:demo
  player-1 => 1225
  player-2 => 1180
  player-3 => 1105

leaderboard:demo:user:player-1
  name = Ada
  description = Solves production incidents before breakfast.

The score data lives in the sorted set, while the user details live in hashes keyed by the same user ID.

The implementation uses:

Leaderboard implementation

The async upsertUser() method writes the score, updates metadata, and then trims the board if it exceeds the configured limit:

public CompletableFuture<LeaderboardEntry> upsertUser(
        String userId,
        double score,
        Map<String, String> metadata
) {
    Map<String, String> payload = coerceMetadata(metadata);

    RedisFuture<Long> scoreFuture = commands.zadd(key, score, userId);
    RedisFuture<Long> metadataFuture = payload.isEmpty()
            ? null
            : commands.hset(metadataKey(userId), payload);

    CompletableFuture<Void> writes = metadataFuture == null
            ? scoreFuture.toCompletableFuture().thenAccept(result -> {})
            : CompletableFuture.allOf(
                    scoreFuture.toCompletableFuture(),
                    metadataFuture.toCompletableFuture()
            );

    return writes
            .thenCompose(ignored -> trimToMaxEntries())
            .thenCompose(trimmedUserIds -> getUserEntry(userId)
                    .thenApply(entry -> entry.withTrimmedUserIds(trimmedUserIds)));
}

To fetch users around a rank, the implementation converts the requested rank and count into a reverse range window:

public CompletableFuture<List<LeaderboardEntry>> getAroundRank(int rank, int count) {
    int normalizedRank = normalizePositiveInt(rank, "rank");
    int normalizedCount = normalizePositiveInt(count, "count");

    return getSize().thenCompose(totalEntries -> {
        if (totalEntries <= normalizedCount) {
            return listAll();
        }

        int halfWindow = normalizedCount / 2;
        int start = Math.max(0, normalizedRank - 1 - halfWindow);
        int maxStart = (int) totalEntries - normalizedCount;
        if (start > maxStart) {
            start = maxStart;
        }
        int end = start + normalizedCount - 1;

        return zrangeWithScoresRev(start, end)
                .toCompletableFuture()
                .thenCompose(entries -> hydrateEntries(entries, start + 1));
    });
}

Metadata design

The leaderboard stores only user IDs and scores in the sorted set. Richer details stay in a separate per-user hash. That means the same user ID can be ranked efficiently while still exposing extra fields such as:

This is a useful pattern when the ranking view and the profile view need different data shapes.

Running the demo

A local demo server is included to show the leaderboard in action (source):

# Compile
javac -cp lettuce-core-6.7.1.RELEASE.jar AsyncRedisLeaderboard.java ReactiveRedisLeaderboard.java DemoServer.java

# Run the demo server
java -cp .:lettuce-core-6.7.1.RELEASE.jar DemoServer

The demo uses the async implementation for the HTTP handlers and provides an interactive web interface where you can:

The demo assumes Redis is running on localhost:6379 but you can specify a different host and port using the --redis-host HOST and --redis-port PORT command-line arguments. Visit http://localhost:8080 in your browser to try it out.

Production usage

This guide uses a deliberately small local demo so you can focus on the Redis leaderboard pattern. In production, you will usually want to add more validation, tighter concurrency control, and application-specific lifecycle rules.

Decide how ties should behave

Redis sorted sets order primarily by score. When two members have the same score, Redis uses the member value as a secondary ordering rule. If your application needs a different tie-breaker, you may want to encode it in the score or store additional state.

Consider how you expire or archive old data

Some leaderboards are permanent, while others reset daily, weekly, or seasonally. Depending on your use case, you may want to:

Keep metadata lightweight

Per-user hashes work best for small, frequently accessed profile details. Large profile documents or rarely used attributes are often better kept in another store, with Redis holding only the fields needed to render the leaderboard quickly.

On this page