Build Real-Time Redis Leaderboard & Ranking Solutions Using Spring Boot

In this post, we’ll build a production-grade fitness leaderboard using Redis Sorted Sets and Spring Boot 4.


Prerequisites

This is the list of all the prerequisites:

  • Spring Boot 3+
  • Maven 3.9.x
  • Java 21
  • Docker & Docker Compose
  • Basic familiarity with Redis
  • Postman

Overview

Leaderboards are deceptively simple on the surface and notoriously difficult to scale. A naive SQL implementation — SELECT user_id, SUM(steps) FROM workouts GROUP BY user_id ORDER BY SUM(steps) DESC LIMIT 10 — is fine for 500 users. At 500,000, it becomes a full table scan on every page load.

In this post, we’ll build a production-grade fitness leaderboard using Redis Sorted Sets and Spring Boot 4. The system ranks users across three metrics (steps, calories burned, distance run) with three time windows (daily, weekly, all-time) — nine leaderboards, all updated in real time, all queryable in O(log N).

What We’re Building

A fitness app leaderboard service with the following shape:

POST /api/leaderboard/workout          Submit a completed workout session
POST /api/leaderboard/score Increment a single metric/period
GET /api/leaderboard/{type}/top Top N users
GET /api/leaderboard/{type}/rank/{id} User's rank, score, and percentile
GET /api/leaderboard/{type}/around/{id} Users ranked immediately above and below

Three leaderboard types mapped to the LeaderboardType enum:

Three time windows mapped to MetricPeriod:

One workout submission updates all nine keys (3 types × 3 periods) in a single request.

User display names are stored in a separate Redis Hash:

fitness:users  →  { "user:alice": "Alice M.", "user:bob": "Bob K.", ... }

This avoids embedding display names in sorted set members, which would make score queries awkward and prevent name changes from propagating correctly.

Why Redis Sorted Sets

Redis Sorted Sets (ZSET) were designed for exactly this problem. Each entry in a sorted set has a member (a string — in our case, userId) and a score (a float64 — steps, calories, or distance). The data structure maintains members in score order at all times, so ranking queries are O(log N) and never require a sort.

The commands you’ll use in every leaderboard:

ZINCRBY is the critical one. Because it increments rather than replaces, you can call it every time a user finishes a workout session, and the leaderboard accumulates correctly. No read-modify-write cycle, no race condition.

Redis CLI demonstration:

# Three users finish a morning run
redis-cli ZINCRBY fitness:lb:steps:daily:2025-07-19 10000 user:alice
redis-cli ZINCRBY fitness:lb:steps:daily:2025-07-19 8500 user:bob
redis-cli ZINCRBY fitness:lb:steps:daily:2025-07-19 12300 user:charlie

# Alice adds an afternoon walk
redis-cli ZINCRBY fitness:lb:steps:daily:2025-07-19 2000 user:alice

# Top 3 today
redis-cli ZREVRANGE fitness:lb:steps:daily:2025-07-19 0 2 WITHSCORES
# 1) "user:charlie"
# 2) "12300"
# 3) "user:alice" <- 10000 + 2000
# 4) "12000"
# 5) "user:bob"
# 6) "8500"

# Alice's rank (0-indexed; add 1 for display)
redis-cli ZREVRANK fitness:lb:steps:daily:2025-07-19 user:alice
# (integer) 1 → rank #2

# Total participants
redis-cli ZCARD fitness:lb:steps:daily:2025-07-19
# (integer) 3

All of the above is O(log N). With 1 million active users, every one of those commands is still sub-millisecond on commodity hardware.

Architecture overview:

Client
│
▼
LeaderboardController
│
▼
LeaderboardService
│ │
▼ ▼
LeaderboardRepository LeaderboardKeyService
│
▼
RedisTemplate<String, String>
│
├── ZSetOperations → ZINCRBY / ZREVRANGE / ZREVRANK / ZSCORE / ZCARD
└── HashOperations → HSET / HMGET (user display names)

Let’s code

Project Structure

redis-leaderboard-spring-boot-demo/
├── docker-compose.yml
├── pom.xml
└── src/
├── main/java/com/boottechsolutions/leaderboard/
│ ├── LeaderboardApplication.java
│ ├── config/RedisConfig.java
│ ├── controller/LeaderboardController.java
│ ├── dto/
│ │ ├── WorkoutSubmitRequest.java
│ │ ├── ScoreIncrementRequest.java
│ │ ├── RankEntry.java
│ │ ├── LeaderboardResponse.java
│ │ └── UserRankResponse.java
│ ├── exception/
│ │ ├── GlobalExceptionHandler.java
│ │ └── UserNotFoundException.java
│ ├── model/
│ │ ├── LeaderboardType.java
│ │ └── MetricPeriod.java
│ ├── repository/LeaderboardRepository.java
│ └── service/
│ ├── LeaderboardKeyService.java
│ └── LeaderboardService.java
└── test/java/com/boottechsolutions/leaderboard/
└── LeaderboardIntegrationTest.java

Step 1 — Docker Compose

Start Redis with memory limits and LRU eviction:

services:
redis:
image: redis:7-alpine
container_name: redis-leaderboard
ports:
- "6379:6379"
command: >
redis-server
--save ""
--appendonly no
--maxmemory 512mb
--maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5

allkeys-lru evicts the least-recently-used keys when memory is full. For a leaderboard, the hottest keys (today’s leaderboard) stay in memory automatically. If you cannot afford to lose any data, switch to noeviction and monitor memory usage carefully.

docker compose up -d

Step 2 — Dependencies

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lettuce connection pooling requires commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

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

Spring Boot’s auto-configuration wires Lettuce (the default Redis client) using properties from application.yml. commons-pool2 enables the Lettuce connection pool — required for any concurrent usage.

Spring Boot 4 ships modular starters: spring-boot-starter-webmvc replaces spring-boot-starter-web, and each technology has its own test starter (spring-boot-starter-data-redis-test). Testcontainers 2.x is managed by the Boot BOM, so no version override is needed — note the artifact rename from junit-jupiter to testcontainers-junit-jupiter.

Step 3 — Redis Configuration

@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(factory);

StringRedisSerializer serializer = new StringRedisSerializer();
template.setKeySerializer(serializer);
template.setValueSerializer(serializer);
template.setHashKeySerializer(serializer);
template.setHashValueSerializer(serializer);

return template;
}
}

Using StringRedisSerializer on all four positions ensures that keys, values, hash keys, and hash values are stored as plain UTF-8 strings. The default JdkSerializationRedisSerializer wraps values in Java serialization bytes, which breaks redis-cli inspection and cross-language interoperability.

application.yml:

spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 2s

max-active: 16 matches a typical Spring Boot thread pool. Size the pool to match your application’s request concurrency — under-sizing causes max-wait timeouts under load.

Step 4 — Models and DTOs

LeaderboardType.java — the three fitness metrics:

public enum LeaderboardType {

STEPS("steps", "Steps Walked"),
CALORIES("calories", "Calories Burned"),
DISTANCE("distance", "Distance Run (km)");

private final String key;
private final String displayName;

LeaderboardType(String key, String displayName) {
this.key = key;
this.displayName = displayName;
}

public String getKey() { return key; }
public String getDisplayName() { return displayName; }
}

MetricPeriod.java — time windows with TTL:

public enum MetricPeriod {

DAILY(Duration.ofHours(48)),
WEEKLY(Duration.ofDays(14)),
ALL_TIME(null);

private final Duration ttl;

MetricPeriod(Duration ttl) { this.ttl = ttl; }

public Optional<Duration> getTtl() { return Optional.ofNullable(ttl); }
}

Request and response records:

public record WorkoutSubmitRequest(
@NotBlank String userId,
@NotBlank String displayName,
@PositiveOrZero long steps,
@PositiveOrZero double caloriesBurned,
@PositiveOrZero double distanceKm
) {}

public record RankEntry(long rank, String userId, String displayName, double score) {}

public record LeaderboardResponse(
String type, String period,
List<RankEntry> entries, long totalParticipants
) {}

public record UserRankResponse(
String userId, String displayName,
long rank, double score,
long totalParticipants, double percentile
) {}

Step 5 — Key Generation

Centralizing key construction in one place makes the naming convention explicit and easy to change:

@Service
public class LeaderboardKeyService {

private static final String KEY_PREFIX = "fitness:lb";
private static final String USERS_KEY = "fitness:users";
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

public String leaderboardKey(LeaderboardType type, MetricPeriod period) {
return leaderboardKey(type, period, LocalDate.now());
}

public String leaderboardKey(LeaderboardType type, MetricPeriod period, LocalDate date) {
return switch (period) {
case DAILY ->
"%s:%s:daily:%s".formatted(KEY_PREFIX, type.getKey(), date.format(DATE_FORMAT));
case WEEKLY ->
"%s:%s:weekly:%s".formatted(KEY_PREFIX, type.getKey(), isoWeekLabel(date));
case ALL_TIME ->
"%s:%s:alltime".formatted(KEY_PREFIX, type.getKey());
};
}

public String usersKey() { return USERS_KEY; }

private String isoWeekLabel(LocalDate date) {
int year = date.get(WeekFields.ISO.weekBasedYear());
int week = date.get(WeekFields.ISO.weekOfWeekBasedYear());
return "%d-W%02d".formatted(year, week);
}
}

This produces keys like:

fitness:lb:steps:daily:2025-07-19
fitness:lb:calories:weekly:2025-W29
fitness:lb:distance:alltime
fitness:users

The fitness: namespace prefix prevents key collisions when this Redis instance is shared with other services. It also makes SCAN fitness:lb:steps:* useful for debugging without scanning the entire keyspace.

Step 6 — Repository

The repository isolates all Redis operations and handles the two patterns the leaderboard uses:

Pattern 1 — Sorted set writes with optional TTL:

public void incrementScore(String key, String member, double increment, Optional<Duration> ttl) {
redisTemplate.opsForZSet().incrementScore(key, member, increment);
ttl.ifPresent(d -> redisTemplate.expire(key, d));
}

Pattern 2 — Batch display name lookup (single HMGET round-trip):

public Map<String, String> getUserDisplayNames(String usersKey, List<String> userIds) {
List<Object> hashKeys = new ArrayList<>(userIds);
List<Object> values = redisTemplate.opsForHash().multiGet(usersKey, hashKeys);

Map<String, String> result = new HashMap<>(userIds.size());
for (int i = 0; i < userIds.size(); i++) {
String userId = userIds.get(i);
Object val = values.get(i);
result.put(userId, val != null ? (String) val : userId);
}
return result;
} Map<String, String> result = new HashMap<>(userIds.size());
for (int i = 0; i < userIds.size(); i++) {
String userId = userIds.get(i);
Object val = values.get(i);
result.put(userId, val != null ? (String) val : userId);
}
return result;
}

Without multiGet, fetching display names for a top-10 leaderboard would require 10 serial Redis calls. multiGet does it in one. This matters both for latency and for connection pool pressure.

The around-user window:

public record TupleWindow(long startRank, List<ZSetOperations.TypedTuple<String>> tuples) {}

public TupleWindow getAroundMember(String key, String member, int range) {
Long rank = redisTemplate.opsForZSet().reverseRank(key, member);
if (rank == null) return new TupleWindow(0, List.of());

long start = Math.max(0, rank - range);
long end = rank + range;

Set<ZSetOperations.TypedTuple<String>> result =
redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
return new TupleWindow(start, result != null ? List.copyOf(result) : List.of());
}

TupleWindow carries startRank so the service can compute correct absolute ranks (e.g., if the user is rank 47 and range is 3, entries start at rank 44 — not rank 1).

Step 7 — Service

@Service
@RequiredArgsConstructor
public class LeaderboardService {

private final LeaderboardRepository repository;
private final LeaderboardKeyService keyService;

public void submitWorkout(WorkoutSubmitRequest request) {
repository.setUserDisplayName(keyService.usersKey(), request.userId(), request.displayName());

incrementAllPeriods(request.userId(), LeaderboardType.STEPS, request.steps());
incrementAllPeriods(request.userId(), LeaderboardType.CALORIES, request.caloriesBurned());
incrementAllPeriods(request.userId(), LeaderboardType.DISTANCE, request.distanceKm());
}

public LeaderboardResponse getTopN(LeaderboardType type, MetricPeriod period, int n) {
String key = keyService.leaderboardKey(type, period);
LeaderboardRepository.TupleWindow window = repository.getTopN(key, n);
long total = repository.getCardinality(key);
return new LeaderboardResponse(type.getDisplayName(), period.name(), toRankEntries(window), total);
}

public UserRankResponse getUserRank(LeaderboardType type, MetricPeriod period, String userId) {
String key = keyService.leaderboardKey(type, period);
Long rank = repository.getReverseRank(key, userId);
Double score = repository.getScore(key, userId);

if (rank == null || score == null) throw new UserNotFoundException(userId);

long total = repository.getCardinality(key);
String displayName = repository.getUserDisplayNames(keyService.usersKey(), List.of(userId)).get(userId);
double percentile = total > 1
? Math.round((1.0 - (double) rank / (total - 1)) * 1000.0) / 10.0
: 100.0;

return new UserRankResponse(userId, displayName, rank + 1, score, total, percentile);
}

public LeaderboardResponse getAroundUser(LeaderboardType type, MetricPeriod period, String userId, int range) {
String key = keyService.leaderboardKey(type, period);
LeaderboardRepository.TupleWindow window = repository.getAroundMember(key, userId, range);
long total = repository.getCardinality(key);
return new LeaderboardResponse(type.getDisplayName(), period.name(), toRankEntries(window), total);
}

private void incrementAllPeriods(String userId, LeaderboardType type, double value) {
if (value <= 0) return;
for (MetricPeriod period : MetricPeriod.values()) {
String key = keyService.leaderboardKey(type, period);
repository.incrementScore(key, userId, value, period.getTtl());
}
}

private List<RankEntry> toRankEntries(LeaderboardRepository.TupleWindow window) {
List<ZSetOperations.TypedTuple<String>> tuples = window.tuples();
if (tuples.isEmpty()) return List.of();

List<String> userIds = tuples.stream()
.map(ZSetOperations.TypedTuple::getValue)
.filter(Objects::nonNull)
.toList();

Map<String, String> names = repository.getUserDisplayNames(keyService.usersKey(), userIds);

List<RankEntry> entries = new ArrayList<>(tuples.size());
long rank = window.startRank() + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
String userId = tuple.getValue();
double score = tuple.getScore() != null ? tuple.getScore() : 0.0;
entries.add(new RankEntry(rank++, userId, names.getOrDefault(userId, userId), score));
}
return entries;
}
}

submitWorkout write count: One call to HSET (display name) + 9 calls to ZINCRBY + up to 6 calls to EXPIRE (daily and weekly for 3 types) = at most 16 Redis operations. At sub-millisecond per operation on a local Redis, this is ~1–2 ms total. In production, use a pipeline to batch these into a single round-trip.

Percentile formula: (1 - rank / (total - 1)) * 100. A user at rank 1 of 1000 scores (1 - 0/999) * 100 = 100%. A user at rank 500 of 1000 scores (1 - 499/999) * 100 ≈ 50%. Top-of-leaderboard = 100th percentile.

Step 8 — Controller

@RestController
@RequestMapping("/api/leaderboard")
@RequiredArgsConstructor
public class LeaderboardController {

private final LeaderboardService leaderboardService;

@PostMapping("/workout")
public ResponseEntity<Void> submitWorkout(@Valid @RequestBody WorkoutSubmitRequest request) {
leaderboardService.submitWorkout(request);
return ResponseEntity.ok().build();
}

@GetMapping("/{type}/top")
public ResponseEntity<LeaderboardResponse> getTop(
@PathVariable LeaderboardType type,
@RequestParam(defaultValue = "DAILY") MetricPeriod period,
@RequestParam(defaultValue = "10") int limit) {
return ResponseEntity.ok(leaderboardService.getTopN(type, period, Math.min(limit, 100)));
}

@GetMapping("/{type}/rank/{userId}")
public ResponseEntity<UserRankResponse> getUserRank(
@PathVariable LeaderboardType type,
@PathVariable String userId,
@RequestParam(defaultValue = "DAILY") MetricPeriod period) {
return ResponseEntity.ok(leaderboardService.getUserRank(type, period, userId));
}

@GetMapping("/{type}/around/{userId}")
public ResponseEntity<LeaderboardResponse> getAroundUser(
@PathVariable LeaderboardType type,
@PathVariable String userId,
@RequestParam(defaultValue = "DAILY") MetricPeriod period,
@RequestParam(defaultValue = "5") int range) {
return ResponseEntity.ok(leaderboardService.getAroundUser(type, period, userId, Math.min(range, 20)));
}
}

The Math.min(limit, 100) and Math.min(range, 20) caps prevent accidentally loading thousands of entries from a large sorted set. For a leaderboard displaying top 100, returning 10,000 entries through the REST layer is almost never intentional.

Step 9 — Running the Application

# Start Redis
docker compose up -d

# Run the application
mvn spring-boot:run

Seed the leaderboard with several users:

Save this as seed-users.sh (it ships with the demo project). It submits one workout for eight users, then a second workout for Alice to show that scores accumulate:

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="${1:-http://localhost:8080}/api/leaderboard"

submit() {
curl -s -o /dev/null -w "%{http_code} $1\n" -X POST "$BASE_URL/workout" \
-H "Content-Type: application/json" \
-d "{\"userId\":\"$1\",\"displayName\":\"$2\",\"steps\":$3,\"caloriesBurned\":$4,\"distanceKm\":$5}"
}

# userId displayName steps calories distanceKm
submit user:alice "Alice M." 10000 420.5 7.8
submit user:bob "Bob K." 14250 610.0 11.2
submit user:carla "Carla R." 8730 365.2 6.1
submit user:david "David L." 12100 540.8 9.4
submit user:emma "Emma S." 17890 720.3 14.6
submit user:farid "Farid B." 6400 280.0 4.3
submit user:grace "Grace T." 11050 495.7 8.9
submit user:hugo "Hugo P." 9320 402.1 6.8

# Second workout for Alice: scores accumulate (ZINCRBY), she moves up
submit user:alice "Alice M." 5200 230.0 4.1
chmod +x seed-users.sh && ./seed-users.sh

Each line prints 200 user:<id>. After the run, Alice has 15,200 daily steps: her two workouts were added together.

Get today’s top 5 steps leaders:

curl -s "http://localhost:8080/api/leaderboard/STEPS/top?period=DAILY&limit=5" | jq .

totalParticipants is 8 while only 5 entries are returned: it comes from ZCARD, not from the size of the page.

Get a user’s rank:

curl -s "http://localhost:8080/api/leaderboard/STEPS/rank/user:alice?period=DAILY" | jq .

Alice beats 6 of the 7 other users: 6 / 7 = 85.7%.

Get users around a user (±2 ranks):

curl -s "http://localhost:8080/api/leaderboard/STEPS/around/user:grace?period=DAILY&range=2" | jq .

This is the “you are here” view: Grace sees the two users just ahead of her and the two just behind.

Query a user who has not trained today:

curl -s "http://localhost:8080/api/leaderboard/STEPS/rank/user:zoe?period=DAILY"
{ "error": "User not found in leaderboard: user:zoe" }

The API returns 404 Not Found, mapped by GlobalExceptionHandler.

Testing

Integration tests use Testcontainers to spin up a real Redis instance — no embedded mocks:

@SpringBootTest
@Testcontainers
class LeaderboardIntegrationTest {

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

@Autowired
private LeaderboardService leaderboardService;

@Autowired
private RedisTemplate<String, String> redisTemplate;

@BeforeEach
void flushRedis() {
Objects.requireNonNull(redisTemplate.getConnectionFactory())
.getConnection()
.serverCommands()
.flushAll();
}

@Test
void getTopN_returns_users_ranked_highest_score_first() {
leaderboardService.submitWorkout(new WorkoutSubmitRequest("user1", "Alice", 8_000, 0, 0));
leaderboardService.submitWorkout(new WorkoutSubmitRequest("user2", "Bob", 12_000, 0, 0));
leaderboardService.submitWorkout(new WorkoutSubmitRequest("user3", "Charlie", 5_000, 0, 0));

LeaderboardResponse board = leaderboardService.getTopN(LeaderboardType.STEPS, MetricPeriod.DAILY, 10);

assertThat(board.entries()).extracting(RankEntry::rank).containsExactly(1L, 2L, 3L);
assertThat(board.entries()).extracting(RankEntry::displayName)
.containsExactly("Bob", "Alice", "Charlie");
}

@Test
void submitWorkout_accumulates_scores_across_multiple_sessions() {
leaderboardService.submitWorkout(new WorkoutSubmitRequest("user1", "Alice", 5_000, 200.0, 3.5));
leaderboardService.submitWorkout(new WorkoutSubmitRequest("user1", "Alice", 3_000, 150.0, 2.0));

LeaderboardResponse steps = leaderboardService.getTopN(LeaderboardType.STEPS, MetricPeriod.DAILY, 10);
assertThat(steps.entries().getFirst().score()).isEqualTo(8_000.0);
}
}

@ServiceConnection (Spring Boot 3.1+) wires the container’s host and port into the Redis auto-configuration automatically — no @DynamicPropertySource boilerplate.

Key scenarios to cover:

  • Score accumulation across multiple submitWorkout calls for the same user
  • Correct descending rank ordering
  • Percentile calculation at boundary positions (rank 1, rank N, rank N/2)
  • UserNotFoundException for a userId with no leaderboard entry
  • getAroundUser absolute rank correctness (user at rank 50 should not show rank 1)
  • All three time windows populated by a single submitWorkout

Performance Considerations

Redis Sorted Set memory cost

A sorted set member uses approximately 64 bytes of overhead plus the member string length plus 8 bytes for the score. A leaderboard with 1 million users and 10-character user IDs consumes roughly:

(64 + 10 + 8) bytes × 1,000,000 = ~82 MB per leaderboard key

With 9 leaderboard keys (3 types × 3 periods), that’s ~740 MB. This fits comfortably in a 512 MB Redis instance — as long as old daily and weekly keys expire on schedule.

Write throughput

A single Redis instance handles 100,000–500,000 ZINCRBY operations per second, depending on hardware and key count. At 100M active users updating scores every second, a single instance is insufficient. Solutions:

Sharding by leaderboard type: Route STEPS to shard A, CALORIES to shard B, DISTANCE to shard C. Eliminates hotspot risk and triples write throughput. Each shard is independent with no cross-shard queries.

Sharding by user range: ZADD leaderboard:steps:daily:shard{userId.hashCode() % N}. Reads require merging N shards, which adds complexity. Only viable when write throughput exceeds what a single shard can handle.

Pipeline batching: When a large batch of scores arrives simultaneously (e.g., from a background job processing workout uploads), use redisTemplate.executePipelined() to batch all ZINCRBY and EXPIRE calls into a single network round-trip:

redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (WorkoutEvent event : events) {
connection.zSetCommands().zIncrBy(
key.getBytes(), event.steps(), event.userId().getBytes());
}
return null;
});

Read optimization

ZREVRANGE with WITHSCORES is already O(log N + M). The only read-side optimization that matters is the display name lookup — always use HMGET (batch) rather than N individual HGET calls. This implementation does that via multiGet.

Common Pitfalls

Rolling TTL vs fixed expiry. The current implementation calls EXPIRE on every ZINCRBY. This resets the TTL on every write, creating a rolling window instead of a fixed one. A daily leaderboard with rolling TTL expires 48 hours after the last activity, not 48 hours after midnight. For most apps this is acceptable. For strict calendar-day leaderboards, calculate the exact seconds until midnight UTC and set TTL only when the key is first created:

long secondsUntilEndOfDay = ChronoUnit.SECONDS.between(
Instant.now(Clock.systemUTC()),
LocalDate.now(ZoneOffset.UTC).atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC)
);
if (Boolean.TRUE.equals(redisTemplate.expire(key, Duration.ofSeconds(secondsUntilEndOfDay + 3600)))) {
// TTL was set — key is new
}

Score precision. Redis scores are IEEE 754 double-precision floats. Integers up to 2⁵³ are represented exactly. For step counts (typically < 100,000 per day), this is not an issue. For high-precision decimal metrics, round to a fixed number of decimal places before storing.

User ID collisions. Sorted set members are strings. If two users share the same userId, their scores merge silently. Enforce user ID uniqueness at the application layer before scores reach Redis.

reverseRangeWithScores returns a Set with undefined order for equal scores. Redis returns equal-score members in lexicographic order by member string. If you display ties, document this behavior so users understand why two 10,000-step users appear in a deterministic but non-intuitive order.

Forgot the around offset. ZREVRANGE returns positions 0..N. If the around-user window starts at position 44, entries must be displayed as ranks 45..53 — not 1..9. TupleWindow.startRank handles this, but any caller that ignores it will show wrong ranks.

Key Takeaways

  • Redis Sorted Sets handle leaderboard reads and writes in O(log N), with ZINCRBY providing atomic score accumulation — no read-modify-write needed
  • Time-windowed leaderboards (daily, weekly, all-time) are just separate keys with different TTLs; one workout submission updates all windows simultaneously
  • Separate display names into a Redis Hash and use HMGET to batch-fetch them — never embed display names in sorted set members or make N individual HGET calls per leaderboard page

The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for Reading !! See you in the next post.

References

👉 Link to Medium blog

Related Posts