Most leaderboard implementations look like this:
SELECT user_id, score, RANK() OVER (ORDER BY score DESC) as rank
FROM scores
WHERE game_id = 'chess-blitz'
ORDER BY score DESC
LIMIT 10;
This works. Until it does not. At a few thousand users it starts to slow. At tens of thousands of concurrent users all submitting scores and querying the leaderboard, you are running a sort over the entire table on every request. The database is doing the same work from scratch each time.
Redis sorted sets maintain sorted order on every write, so reads are O(log n) lookups into a pre-sorted structure. The leaderboard is always ready. No query-time sort.
What a Sorted Set Actually Is
A Redis sorted set (zset) stores a collection of unique members, each associated with a floating-point score. Members are always ordered by score. Two members with the same score are ordered lexicographically.
The key operations:
| Command | Description | Complexity |
|---|---|---|
ZADD key score member | Add or update a member | O(log n) |
ZRANK key member | Rank of member (0-indexed, ascending) | O(log n) |
ZREVRANK key member | Rank from top (0 = highest score) | O(log n) |
ZRANGE key start stop REV WITHSCORES | Top N members with scores | O(log n + k) |
ZSCORE key member | Score of a specific member | O(1) |
ZCARD key | Total member count | O(1) |
That is your entire leaderboard API. Five commands.
Building the All-Time Leaderboard
Let us build this in TypeScript with the redis package.
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const LEADERBOARD_KEY = "leaderboard:chess-blitz:alltime";
// Submit a score: ZADD with NX only adds if higher than existing
async function submitScore(userId: string, score: number): Promise<void> {
// GT flag: only update if new score is greater than existing score
await redis.zAdd(LEADERBOARD_KEY, { score, value: userId }, { GT: true });
}
// Get top N players
async function getTopPlayers(n: number): Promise<Array<{ userId: string; score: number; rank: number }>> {
const results = await redis.zRangeWithScores(LEADERBOARD_KEY, 0, n - 1, { REV: true });
return results.map((entry, index) => ({
userId: entry.value,
score: entry.score,
rank: index + 1, // 1-indexed for display
}));
}
// Get a specific user's rank and score
async function getUserRank(userId: string): Promise<{ rank: number; score: number } | null> {
const [rank, score] = await Promise.all([
redis.zRevRank(LEADERBOARD_KEY, userId),
redis.zScore(LEADERBOARD_KEY, userId),
]);
if (rank === null || score === null) return null;
return { rank: rank + 1, score }; // zRevRank is 0-indexed
}
// Total players on leaderboard
async function getPlayerCount(): Promise<number> {
return redis.zCard(LEADERBOARD_KEY);
}
That is a complete leaderboard. Let us add the parts that make it production-ready.
Score Submission: Only Keep Personal Best
The GT flag in ZADD only updates the score if the new value is greater than the existing score. This is exactly what you want for a personal-best leaderboard: submit 850, then submit 700, and 850 stays.
// Without GT: every score overwrites
await redis.zAdd(key, { score: 700, value: "user:42" });
// user:42 now has score 700, even if they had 850 before
// With GT: only update if improvement
await redis.zAdd(key, { score: 700, value: "user:42" }, { GT: true });
// user:42 keeps their 850
If you want to store all scores (like a run-based game where each attempt is separate), use a different key structure: leaderboard:game:run:{runId} and a separate set for aggregation.
Time-Windowed Leaderboards
All-time leaderboards are useful. Daily and weekly leaderboards drive engagement. Here is the pattern:
Use a different key per time window. Daily leaderboard key includes the date. On day rollover, a new key starts fresh.
function getDailyKey(gameId: string): string {
const date = new Date().toISOString().split("T")[0]; // "2026-06-08"
return `leaderboard:${gameId}:daily:${date}`;
}
function getWeeklyKey(gameId: string): string {
const now = new Date();
const dayOfWeek = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
const week = monday.toISOString().split("T")[0];
return `leaderboard:${gameId}:weekly:${week}`;
}
async function submitScoreAllWindows(userId: string, gameId: string, score: number): Promise<void> {
const pipeline = redis.multi();
const keys = [
`leaderboard:${gameId}:alltime`,
getDailyKey(gameId),
getWeeklyKey(gameId),
];
for (const key of keys) {
pipeline.zAdd(key, { score, value: userId }, { GT: true });
// Auto-expire time-windowed keys so they clean themselves up
pipeline.expire(key, 60 * 60 * 24 * 8); // 8 days (weekly + buffer)
}
await pipeline.exec();
}
All three leaderboards updated in one round trip via pipeline. The expire call ensures old daily/weekly keys clean themselves up after 8 days without a manual cleanup job.
Set TTL on time-windowed leaderboard keys. Without it, old keys accumulate indefinitely. A daily leaderboard key from January 2026 is still in Redis in December unless you expire it.
Pagination: Showing Page 2 of the Leaderboard
ZRANGE with REV and byte offsets handles pagination cleanly:
async function getLeaderboardPage(
gameId: string,
page: number,
pageSize: number = 25
): Promise<{
players: Array<{ userId: string; score: number; rank: number }>;
total: number;
}> {
const key = `leaderboard:${gameId}:alltime`;
const start = (page - 1) * pageSize;
const stop = start + pageSize - 1;
const [results, total] = await Promise.all([
redis.zRangeWithScores(key, start, stop, { REV: true }),
redis.zCard(key),
]);
return {
players: results.map((entry, index) => ({
userId: entry.value,
score: entry.score,
rank: start + index + 1,
})),
total,
};
}
Page 1: indices 0-24. Page 2: indices 25-49. Page 3: indices 50-74. Redis handles the range lookup in O(log n + k) where k is the page size.
Showing a User's Context: Ranks Around Them
One of the better UX patterns for leaderboards: you are ranked #847, and you can see #844 through #850. Gives motivation to climb.
async function getUserContext(
userId: string,
gameId: string,
radius: number = 3
): Promise<{
user: { userId: string; score: number; rank: number } | null;
nearby: Array<{ userId: string; score: number; rank: number }>;
}> {
const key = `leaderboard:${gameId}:alltime`;
const [rank, score] = await Promise.all([
redis.zRevRank(key, userId),
redis.zScore(key, userId),
]);
if (rank === null || score === null) {
return { user: null, nearby: [] };
}
const start = Math.max(0, rank - radius);
const stop = rank + radius;
const results = await redis.zRangeWithScores(key, start, stop, { REV: true });
return {
user: { userId, score, rank: rank + 1 },
nearby: results.map((entry, index) => ({
userId: entry.value,
score: entry.score,
rank: start + index + 1,
})),
};
}
One extra range query after the rank lookup. The user sees themselves in context.
Handling Ties
Sorted sets break ties lexicographically by member value. If two users both have score 1000, the one whose userId comes first alphabetically gets the higher rank. This is deterministic but arbitrary, and it means two users with the same score are not shown as "tied" by default.
If you want true tie handling (both users ranked #5, next user is #7), you need to handle it in application logic:
async function getTopPlayersWithTies(n: number, gameId: string) {
const key = `leaderboard:${gameId}:alltime`;
// Fetch more than n to ensure we capture all ties at the boundary
const results = await redis.zRangeWithScores(key, 0, n + 10, { REV: true });
let currentRank = 1;
let processed = 0;
const output = [];
for (let i = 0; i < results.length; i++) {
if (processed >= n && results[i].score !== results[i - 1]?.score) break;
if (i > 0 && results[i].score < results[i - 1].score) {
currentRank = i + 1;
}
output.push({
userId: results[i].value,
score: results[i].score,
rank: currentRank,
});
processed++;
}
return output;
}
Most leaderboards do not need this. Gaming leaderboards at scale rarely have ties because scores are precise (milliseconds, not round numbers). But if you do need it, this handles it without an extra database query.
Increment vs Absolute Score
Two different leaderboard semantics:
Absolute score: user submits their best score (chess rating, personal best time). Use ZADD with GT flag.
Cumulative score: user accumulates points over time (total kills, total coins). Use ZINCRBY.
// Cumulative: add 50 points to user's existing score
await redis.zIncrBy(`leaderboard:${gameId}:alltime`, 50, userId);
// Absolute best: only update if new score is higher
await redis.zAdd(`leaderboard:${gameId}:alltime`, { score: newScore, value: userId }, { GT: true });
ZINCRBY is atomic. Safe for concurrent score submissions without any locking.
Full API: Putting It Together
export class Leaderboard {
constructor(
private redis: ReturnType<typeof createClient>,
private gameId: string
) {}
private key(window: "alltime" | "daily" | "weekly"): string {
if (window === "alltime") return `lb:${this.gameId}:alltime`;
if (window === "daily") {
const date = new Date().toISOString().split("T")[0];
return `lb:${this.gameId}:daily:${date}`;
}
const now = new Date();
const day = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1));
return `lb:${this.gameId}:weekly:${monday.toISOString().split("T")[0]}`;
}
async submit(userId: string, score: number): Promise<void> {
const pipeline = this.redis.multi();
for (const window of ["alltime", "daily", "weekly"] as const) {
pipeline.zAdd(this.key(window), { score, value: userId }, { GT: true });
if (window !== "alltime") pipeline.expire(this.key(window), 691200);
}
await pipeline.exec();
}
async getTop(n: number, window: "alltime" | "daily" | "weekly" = "alltime") {
const results = await this.redis.zRangeWithScores(this.key(window), 0, n - 1, { REV: true });
return results.map((e, i) => ({ userId: e.value, score: e.score, rank: i + 1 }));
}
async getRank(userId: string, window: "alltime" | "daily" | "weekly" = "alltime") {
const [rank, score] = await Promise.all([
this.redis.zRevRank(this.key(window), userId),
this.redis.zScore(this.key(window), userId),
]);
if (rank === null || score === null) return null;
return { rank: rank + 1, score, total: await this.redis.zCard(this.key(window)) };
}
}
// Usage
const lb = new Leaderboard(redis, "chess-blitz");
await lb.submit("user:42", 1247);
const top10 = await lb.getTop(10);
const myRank = await lb.getRank("user:42", "daily");
When Not to Use This
Redis sorted sets work well for leaderboards where:
- Scores update frequently
- You need real-time rank lookups
- The leaderboard fits in memory (millions of users: fine, billions: plan accordingly)
Use a database instead when:
- You need complex queries on leaderboard data (filter by country, age group, device)
- You need audit history of score changes
- Leaderboard data must survive Redis being wiped
A common pattern: Redis for real-time display, Postgres as the source of truth. Write scores to both. Read the leaderboard from Redis. If Redis is lost, rebuild from Postgres.
async function submitScore(userId: string, gameId: string, score: number): Promise<void> {
// Source of truth
await db.score.upsert({
where: { userId_gameId: { userId, gameId } },
update: { score: { set: score }, updatedAt: new Date() },
create: { userId, gameId, score },
});
// Real-time display
await redis.zAdd(`lb:${gameId}:alltime`, { score, value: userId }, { GT: true });
}
Two writes, reads from Redis. If Redis dies, your rebuild script is SELECT user_id, MAX(score) FROM scores GROUP BY user_id piped into ZADD.
That is the whole leaderboard. Five Redis commands, one class, one rebuild script.
