Designing Real-Time Game Leaderboards with Redis Sorted Sets
1. Overview
Leaderboards are found in nearly every online game. The problem sounds simple: rank players by score. But when you have millions of players and thousands of matches ending every second, ORDER BY score DESC on a relational database stops being feasible.
This article walks through the full design: requirements, data modeling on Redis sorted sets, APIs, processing logic, and tricky points like tie-breaking, duplicate updates, data consistency, and traffic spikes. After reading, you’ll be able to build a working leaderboard with Spring Boot 3 and Redis, and understand its limits.
2. Problem Requirements
2.1. Functional Requirements
- Each season has its own leaderboard. A player’s score is the highest score achieved in any single match.
- View top N (typically 100).
- View your own rank, even if you’re ranked 3,456,789.
- View “around me”: 5 players above and 5 below.
- When two players have the same score, whoever achieved it first ranks higher.
2.2. Scale and Constraints
- 10 million players per season.
- Average 2,000 match results per second. During peak hours or special events, this can spike to 20,000/second.
- Read-heavy: the top 100 screen is viewed constantly.
- Acceptable display latency: under 1–2 seconds after a match ends.
- A match result can be resent multiple times due to retries. Duplicates must not corrupt the board.
3. Why Redis Sorted Set
A sorted set (ZSET) stores member → score pairs and maintains order by score. Internally it uses a skip list combined with a hash table, giving these characteristics:
| Operation | Command | Complexity |
|---|---|---|
| Update score | ZADD |
O(log N) |
| Get rank | ZREVRANK |
O(log N) |
| Get top N / range | ZREVRANGE |
O(log N + M) |
| Get player’s score | ZSCORE |
O(1) |
With N = 10 million, log N is only about 23 steps. Compare this to SQL: to find a player’s rank, you’d count rows with higher scores. Even with an index, this query becomes expensive at scale.
For memory, each member costs roughly 80–120 bytes depending on ID length. 10 million players fit around 1 GB, comfortably in a single Redis node.
4. Overall Architecture
Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
│ ▲
└──► Persist Consumer ──► PostgreSQL (source of truth) │
│
Client ──► Leaderboard API (Spring Boot) ── cache top 100 in RAM ──────────┘
Three key decisions:
- PostgreSQL is the source of truth. Redis is just an index for fast reads. If Redis goes down, you rebuild it from the database.
- Kafka sits in between to absorb traffic spikes. Game servers don’t slow down when Redis is busy.
- Read API is separated from write flow. Top 100 is cached short-term in memory on each API instance.
The table storing match results in PostgreSQL:
CREATE TABLE match_results (
match_id UUID NOT NULL,
player_id BIGINT NOT NULL,
season_id INT NOT NULL,
points INT NOT NULL CHECK (points >= 0),
achieved_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (match_id, player_id)
);
CREATE INDEX idx_season_player ON match_results (season_id, player_id);
The composite primary key (match_id, player_id) makes duplicate writes harmless: just use INSERT ... ON CONFLICT DO NOTHING.
5. Data Model on Redis
5.1. Keys and Members
lb:{s12} → ZSET, member = playerId, score = encoded points
lb:{s12}:dedup:<matchId>:<playerId> → STRING, marks result as processed (TTL 1 day)
The {s12} part is a hash tag. On Redis Cluster, all keys with the same hash tag land in the same slot. This is mandatory if you want to use them together in a Lua script.
5.2. Handling Ties by Encoding Time into Score
Redis sorts members with the same score lexicographically by member name, which is meaningless for a game. We need “earlier achiever ranks higher”, so we embed the timestamp into the score.
A Redis score is a double, accurately representing any integer up to 2^53. We split the 53 bits like this:
- Top 21 bits: points (max around 2 million).
- Bottom 32 bits:
MAX_TIME - seconds since season start. An earlier achiever gets a larger value, so ranks higher when sorted descending.
public final class ScoreCodec {
private static final int TIME_BITS = 32;
private static final long MAX_TIME = (1L << TIME_BITS) - 1;
public static final long MAX_POINTS = (1L << 21) - 1;
private ScoreCodec() {}
public static long encode(long points, long secondsSinceSeasonStart) {
if (points < 0 || points > MAX_POINTS) {
throw new IllegalArgumentException("points out of range: " + points);
}
if (secondsSinceSeasonStart < 0 || secondsSinceSeasonStart > MAX_TIME) {
throw new IllegalArgumentException("time out of range");
}
return (points << TIME_BITS) | (MAX_TIME - secondsSinceSeasonStart);
}
public static long points(double encoded) {
return ((long) encoded) >>> TIME_BITS;
}
}
encode returns a long. Send this to Redis as a string of digits to avoid any floating-point precision issues. When reading back, points() right-shifts by 32 bits to extract the original score. The cast (long) encoded is exact because the value never exceeds 2^53.
Note: If your game has scores over 2 million, reduce time resolution (e.g., by minute instead of second) to free up bits for points. Never exceed 53 bits. Beyond that,
doublerounds, and two players with different scores might get the same encoded value.
Tests for the codec, since this is where mistakes happen most:
class ScoreCodecTest {
@Test
void higherPointsAlwaysRankAbove() {
long late = ScoreCodec.encode(1001, 3_000_000);
long early = ScoreCodec.encode(1000, 0);
assertThat(late).isGreaterThan(early);
}
@Test
void samePointsEarlierAchieverRanksAbove() {
long alice = ScoreCodec.encode(500, 100);
long bob = ScoreCodec.encode(500, 200);
assertThat(alice).isGreaterThan(bob);
}
@Test
void decodeSurvivesDoubleRoundTrip() {
long encoded = ScoreCodec.encode(ScoreCodec.MAX_POINTS, 12_345);
double fromRedis = (double) encoded;
assertThat(ScoreCodec.points(fromRedis)).isEqualTo(ScoreCodec.MAX_POINTS);
}
}
The third test simulates Redis returning the score as a double. It verifies that even at the maximum value, decoding works correctly.
6. Score Updates: Atomic and Idempotent
Required dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
Score updates must satisfy two requirements simultaneously:
- Only write if the new score is higher than the current one.
- Skip results that have already been processed.
If you do this in two separate commands like ZSCORE then ZADD, two consumers can interleave and a lower score might overwrite a higher one. Bundle everything into a Lua script. Redis executes scripts single-threaded and atomically.
@Service
public class LeaderboardWriter {
private static final RedisScript<Long> SUBMIT = RedisScript.of("""
if not redis.call('SET', KEYS[2], '1', 'NX', 'EX', ARGV[3]) then
return -1
end
local cur = redis.call('ZSCORE', KEYS[1], ARGV[2])
if cur and tonumber(cur) >= tonumber(ARGV[1]) then
return 0
end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
return 1
""", Long.class);
private final StringRedisTemplate redis;
public LeaderboardWriter(StringRedisTemplate redis) {
this.redis = redis;
}
public long submit(MatchResult r) {
String board = "lb:{s" + r.seasonId() + "}";
String dedup = board + ":dedup:" + r.matchId() + ":" + r.playerId();
long score = ScoreCodec.encode(r.points(), r.secondsSinceSeasonStart());
return redis.execute(SUBMIT, List.of(board, dedup),
Long.toString(score), Long.toString(r.playerId()), "86400");
}
}
public record MatchResult(UUID matchId, long playerId, int seasonId,
int points, long secondsSinceSeasonStart) {}
The script returns three values:
-1: duplicate.SET NXfails because this match result was already processed.0: not a new record; the board is unchanged.1: record updated.
You can use the return value to count metrics or send a notification like “You’ve risen in rank!”.
Note: Redis 6.2+ has
ZADD key GT score memberto write only if the score is greater. Without dedup, you could use that instead. Here we still need a script because we must combine dedup checking into the same atomic operation.
If the game rule is cumulative points (ZINCRBY) instead of highest score, idempotency becomes mandatory. Reason: adding twice is wrong. Then the time component in the encoded score also needs updating on each addition, making the script more complex.
7. Read APIs
@RestController
@RequestMapping("/seasons/{seasonId}/leaderboard")
public class LeaderboardController {
private final StringRedisTemplate redis;
public LeaderboardController(StringRedisTemplate redis) {
this.redis = redis;
}
public record Entry(long rank, long playerId, long points) {}
@GetMapping("/players/{playerId}/around")
public List<Entry> around(@PathVariable int seasonId, @PathVariable long playerId,
@RequestParam(defaultValue = "5") int radius) {
String key = "lb:{s" + seasonId + "}";
Long rank = redis.opsForZSet().reverseRank(key, Long.toString(playerId));
if (rank == null) {
return List.of();
}
long start = Math.max(0, rank - radius);
return range(key, start, rank + radius);
}
List<Entry> range(String key, long start, long end) {
var tuples = redis.opsForZSet().reverseRangeWithScores(key, start, end);
List<Entry> result = new ArrayList<>();
long rank = start + 1;
for (var t : tuples) {
result.add(new Entry(rank++, Long.parseLong(t.getValue()),
ScoreCodec.points(t.getScore())));
}
return result;
}
}
reverseRank returns the rank (0-indexed) in descending order, so the display rank is rank + 1. If a player has no score in the season, it returns null and the API returns an empty list. Calling /seasons/12/leaderboard/players/42/around gives:
[{"rank":3456784,"playerId":901,"points":1520},
...
{"rank":3456789,"playerId":42,"points":1518},
...
{"rank":3456794,"playerId":77,"points":1515}]
The two commands reverseRank and reverseRangeWithScores are not atomic with each other. Between them, the board might change slightly. For a leaderboard screen, a few hundred milliseconds of skew is acceptable. If you need strict consistency, wrap them in a Lua script.
8. Challenges and Solutions
8.1. Hot Key at Top 100
Every client queries top 100, and they all hit a single key on a single node. Adding replicas helps somewhat, but the most effective approach is to cache it in memory on each API instance and refresh every second:
@Component
public class TopCache {
private final LeaderboardController reader;
private volatile List<LeaderboardController.Entry> top100 = List.of();
public TopCache(LeaderboardController reader) {
this.reader = reader;
}
@Scheduled(fixedRate = 1000)
void refresh() {
top100 = reader.range("lb:{s12}", 0, 99);
}
public List<LeaderboardController.Entry> get() {
return top100;
}
}
Remember to enable @EnableScheduling. With 50 API instances, Redis receives only 50 top-100 queries per second, regardless of how many millions of clients exist. In the example, the season is hardcoded for brevity. In practice, fetch the current season from configuration.
8.2. Write Traffic Spikes
When an event ends, tens of thousands of matches might finish simultaneously. Kafka acts as a buffer. The consumer processes in batches and sends commands via pipeline to reduce network round-trips:
@KafkaListener(topics = "match-results", batch = "true", groupId = "leaderboard")
public void onBatch(List<MatchResult> results) {
results.forEach(writer::submit);
}
For even better throughput, you can batch the execute calls into redis.executePipelined(...). Also partition the topic by playerId. This ensures all results for one player are processed sequentially on the same partition, reducing contention. The Lua script maintains correctness even with contention.
When Redis falls behind, consumer lag increases, but game servers don’t suffer. Players just see the board update a few seconds late.
8.3. Consistency Between Redis and Database
Redis can lose data if a node dies before fsync. Since PostgreSQL is the source of truth, run a job periodically to rebuild the board:
SELECT player_id,
MAX(points) AS best,
MIN(achieved_at) FILTER (WHERE points = best_points) AS first_at
FROM (
SELECT player_id, points, achieved_at,
MAX(points) OVER (PARTITION BY player_id) AS best_points
FROM match_results
WHERE season_id = 12
) t
GROUP BY player_id;
Write results to a temporary key like lb:{s12}:rebuild, then atomically swap it with the real key using RENAME. Users never see an empty board in between.
Run this job periodically for reconciliation too. If the member count in Redis drifts from the database, you’ll catch it.
8.4. Daily, Weekly Boards, and Cleanup
Daily boards use keys like lb:{s12}:d:2026-10-12 with EXPIRE of 3 days. Weekly boards can be aggregated from daily ones using ZUNIONSTORE ... AGGREGATE MAX. At season end, snapshot the top 1000 to the database for rewards, then delete large keys using UNLINK instead of DEL to avoid blocking Redis.
8.5. Friends’ Leaderboard
With a friends list of around 200, fetch scores for all using ZMSCORE lb:{s12} id1 id2 ... in one call, then sort in Java. No need to create a separate ZSET per person.
9. When NOT to Use This Design
- Scale beyond one node. A ZSET can’t be split across a Redis Cluster. If you have hundreds of millions of players in a single board, you must partition by score range or region, accepting that global rank is approximate.
- Complex multi-dimensional ranking criteria. E.g., points first, then win rate, then match count. Fifty-three bits won’t encode everything.
- Exact ranks required for financial purposes. Payouts based on rank should be recalculated from the database at settlement time, not pulled live from Redis.
- Small player counts. A few tens of thousands of rows and PostgreSQL with a
(season_id, points DESC)index suffice. You save maintaining another system.
10. Conclusion
Redis sorted set enables updates, rank lookups, and top-N queries in O(log N) time. It’s the natural foundation for real-time leaderboards.
The hard part lies in surrounding details:
- Encoding tie-breaking into the score without exceeding 53 bits.
- Using Lua scripts to be both record-only-if-highest and idempotent.
- Placing Kafka upstream to absorb traffic spikes.
- Caching top 100 in RAM to avoid hot keys.
- Keeping PostgreSQL as source of truth so you can rebuild Redis anytime.
When implementing, write tests for ScoreCodec first. A bit-overflow bug here silently corrupts the entire board.