Designing a Leaderboard System: Sorted Sets, Skip Lists, and Real-Time Rankings at Scale
A deep-dive into leaderboard system design for senior engineers. Covers the limits of SQL ORDER BY, Redis sorted set internals and skip list mechanics, sharded leaderboards for massive scale, time-windowed rankings, and tie-breaking strategies with TypeScript examples throughout.
A leaderboard sounds simple: sort users by score and show the top N. The first implementation fits in a SQL query. The problem is that “show the top N” is rarely what you actually ship. You also need “show the rank of a specific user,” “show the users near me in rank,” “maintain separate daily, weekly, and all-time boards,” and do all of this for tens of millions of users at read latency that does not exceed 20ms.
That combination of requirements is where naive SQL approaches collapse and where Redis sorted sets earn their place in most production leaderboard designs.
This article walks through the full design: from why ORDER BY breaks at scale, through how Redis sorted sets work internally, to sharding strategies, time-windowed boards, and tie-breaking. TypeScript examples throughout.
Why SQL ORDER BY Breaks
The most common starting point is a scores table:
CREATE TABLE scores (
user_id BIGINT PRIMARY KEY,
score INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Top 100
SELECT user_id, score
FROM scores
ORDER BY score DESC
LIMIT 100;
-- Rank of a specific user
SELECT COUNT(*) + 1 AS rank
FROM scores
WHERE score > (SELECT score FROM scores WHERE user_id = $1);
For tens of thousands of users with an index on score, this is fine. The rank lookup does a range scan on the index and returns quickly.
At one million users, the rank lookup is a COUNT over potentially half the table. The index helps, but COUNT operations are not O(log N) in PostgreSQL. At ten million users with frequent score updates (every game event, every upvote, every transaction), you have write contention on the index plus slow reads on every rank query. You can add a materialized view and refresh it on a schedule, but then your ranks are stale.
There are also practical write amplification problems. A leaderboard that must show a user’s rank in real-time cannot tolerate a 60-second materialized view refresh lag. You could try maintaining a separate rank column and updating it on every score change, but that update requires a range lock and re-numbering every affected row. For a competitive leaderboard where many users submit scores simultaneously during a live event, that pattern will serialize writes and grind throughput to a halt.
The architectural mismatch is that relational databases are optimized for flexible queries over structured data, not for maintaining a continuously updated, globally ordered sequence with O(log N) rank lookups on every mutation. Sorted sets are purpose-built for exactly that.
Redis Sorted Sets
A Redis sorted set (a zset) associates each member with a floating-point score. The set is stored in score order. The core operations you need for a leaderboard:
ZADD leaderboard:global 4200 "user:1001" # O(log N) insert or update
ZRANK leaderboard:global "user:1001" # O(log N) 0-based rank (ascending)
ZREVRANK leaderboard:global "user:1001" # O(log N) rank by descending score
ZREVRANGE leaderboard:global 0 99 WITHSCORES # O(log N + M) top 100
ZSCORE leaderboard:global "user:1001" # O(1) score lookup
ZCOUNT leaderboard:global 1000 +inf # O(log N) count above threshold
ZCARD leaderboard:global # O(1) total member count
All of these are bounded by O(log N) for single-element operations, regardless of how large the set grows. That is the fundamental property that makes sorted sets suitable for leaderboards.
Skip List Internals
Under the hood, Redis implements sorted sets as a combination of two data structures: a hash table (for O(1) score lookups by member) and a skip list (for O(log N) rank and range operations).
The skip list is the interesting part. A skip list is a probabilistic data structure that maintains a sorted linked list with multiple levels of express lanes:
Level 3: [head] ----------------> [user:5000, 9800] ---------> [tail]
Level 2: [head] -> [user:1001, 4200] -> [user:5000, 9800] ---> [tail]
Level 1: [head] -> [user:1001, 4200] -> [user:3002, 6100] -> [user:5000, 9800] -> [tail]
Level 0: [head] -> [user:0099, 200] -> [user:1001, 4200] -> [user:2200, 5000] -> [user:3002, 6100] -> [user:5000, 9800] -> [tail]
Each node participates in a random number of levels, chosen with probability 0.25 per additional level. A search starts at the highest level and drops down when the next node at the current level overshoots the target. On average, a search touches O(log N) nodes.
Crucially, each forward pointer at level 0 also stores a span: the number of nodes it skips. During a rank query (ZREVRANK), Redis traverses the skip list and accumulates spans. The rank is the sum of spans along the search path. This is why rank lookup is O(log N) and not O(N). A binary search tree can give you O(log N) lookups too, but maintaining the rank count during rebalancing is non-trivial. Skip lists track spans naturally during insertion.
Redis’s implementation adds one more detail: when scores are equal, members are ordered lexicographically by their string value. This matters for tie-breaking, which we will cover later.
One practical implication of the skip list structure: sorted sets use more memory than plain sets or hashes because each node carries multiple forward pointers. The memory overhead is approximately 64-80 bytes per entry on top of the actual member string and score. This is worth factoring into capacity planning before you decide how many time windows to maintain simultaneously.
Another: Redis stores sorted sets as a ziplist (a compact memory encoding) when the set has fewer than 128 members and all member strings are shorter than 64 bytes. Once either threshold is exceeded, Redis converts the ziplist to a skip list. The ziplist uses less memory but requires O(N) rank lookups. For leaderboards of any meaningful size you will always be in skip list mode, but it is worth knowing the threshold exists so you are not surprised if a very small test set returns different performance characteristics than production.
Basic TypeScript Implementation
Using ioredis:
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
const BOARD_KEY = "leaderboard:global";
// Submit or update a score
async function submitScore(userId: string, score: number): Promise<void> {
// ZADD with NX only inserts if the member is new; use without NX to update
// GT (greater-than) flag: only update if the new score is higher
await redis.zadd(BOARD_KEY, "GT", score, `user:${userId}`);
}
// Get a user's rank (1-based, descending by score)
async function getRank(userId: string): Promise<number | null> {
// ZREVRANK returns 0-based rank; null if member does not exist
const rank = await redis.zrevrank(BOARD_KEY, `user:${userId}`);
return rank !== null ? rank + 1 : null;
}
// Get the top N users with scores
async function getTopN(n: number): Promise<Array<{ userId: string; score: number; rank: number }>> {
// ZREVRANGE returns members in descending score order
const results = await redis.zrevrange(BOARD_KEY, 0, n - 1, "WITHSCORES");
const entries: Array<{ userId: string; score: number; rank: number }> = [];
for (let i = 0; i < results.length; i += 2) {
const member = results[i];
const score = parseFloat(results[i + 1]);
const userId = member.replace("user:", "");
entries.push({ userId, score, rank: Math.floor(i / 2) + 1 });
}
return entries;
}
// Get a user's score
async function getScore(userId: string): Promise<number | null> {
const score = await redis.zscore(BOARD_KEY, `user:${userId}`);
return score !== null ? parseFloat(score) : null;
}
The GT flag on ZADD is worth calling out. In game leaderboards, you typically only want to record a user’s best score, not overwrite a 9,800-point run with a 3,200-point run. ZADD GT updates the score only if the new value is greater than the current value.
Relative Ranking: “You are rank 4,532 of 1.2M”
A key UX feature is showing users their rank plus context. Two components: the absolute rank, and the total member count for the denominator.
interface RankContext {
rank: number;
totalUsers: number;
percentile: number;
nearbyUsers: Array<{ userId: string; score: number; rank: number }>;
}
async function getRankContext(userId: string): Promise<RankContext | null> {
const member = `user:${userId}`;
// Fetch rank, total count, and score in a single pipeline
const pipeline = redis.pipeline();
pipeline.zrevrank(BOARD_KEY, member);
pipeline.zcard(BOARD_KEY);
pipeline.zscore(BOARD_KEY, member);
const [[, rankRaw], [, totalRaw], [, scoreRaw]] = await pipeline.exec() as any[];
if (rankRaw === null) return null;
const rank: number = (rankRaw as number) + 1;
const totalUsers: number = totalRaw as number;
const score = parseFloat(scoreRaw as string);
// Percentile: what fraction of users this user outranks
const percentile = ((totalUsers - rank) / totalUsers) * 100;
// Fetch the 5 users above and 4 users below for context
const zeroBasedRank = rank - 1;
const windowStart = Math.max(0, zeroBasedRank - 5);
const windowEnd = zeroBasedRank + 4;
const windowResults = await redis.zrevrange(BOARD_KEY, windowStart, windowEnd, "WITHSCORES");
const nearbyUsers: Array<{ userId: string; score: number; rank: number }> = [];
for (let i = 0; i < windowResults.length; i += 2) {
nearbyUsers.push({
userId: windowResults[i].replace("user:", ""),
score: parseFloat(windowResults[i + 1]),
rank: windowStart + Math.floor(i / 2) + 1,
});
}
return { rank, totalUsers, percentile, nearbyUsers };
}
The pipeline call is important. ZREVRANK, ZCARD, and ZSCORE are three round trips if issued separately. A pipeline sends all three in a single TCP round trip and receives all three responses. At 20ms average Redis latency, that is the difference between 60ms and 20ms for a single user request.
Time-Windowed Leaderboards
Most production leaderboards need multiple time windows: all-time, this month, this week, today. The standard pattern with Redis sorted sets is to maintain separate keys per window and expire them automatically.
function getBoardKeys(now: Date): { daily: string; weekly: string; monthly: string; allTime: string } {
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
const day = String(now.getUTCDate()).padStart(2, "0");
// ISO week number
const startOfYear = new Date(Date.UTC(year, 0, 1));
const weekNumber = Math.ceil(((now.getTime() - startOfYear.getTime()) / 86_400_000 + startOfYear.getUTCDay() + 1) / 7);
const week = String(weekNumber).padStart(2, "0");
return {
daily: `leaderboard:daily:${year}-${month}-${day}`,
weekly: `leaderboard:weekly:${year}-W${week}`,
monthly: `leaderboard:monthly:${year}-${month}`,
allTime: `leaderboard:alltime`,
};
}
async function submitScoreWindowed(userId: string, points: number): Promise<void> {
const keys = getBoardKeys(new Date());
const member = `user:${userId}`;
const pipeline = redis.pipeline();
// ZADD with INCR: add points to the existing score (not replace)
pipeline.zadd(keys.daily, "INCR", points, member);
pipeline.zadd(keys.weekly, "INCR", points, member);
pipeline.zadd(keys.monthly, "INCR", points, member);
pipeline.zadd(keys.allTime, "INCR", points, member);
// Set TTL only on windowed keys; expire slightly after window closes
// Daily key: expire after 25 hours (1 hour buffer for late queries)
pipeline.expire(keys.daily, 25 * 60 * 60);
// Weekly key: expire after 8 days
pipeline.expire(keys.weekly, 8 * 24 * 60 * 60);
// Monthly key: expire after 32 days
pipeline.expire(keys.monthly, 32 * 24 * 60 * 60);
await pipeline.exec();
}
Using ZADD INCR instead of ZADD is the correct choice when scores accumulate (points scored, upvotes received, levels completed). ZADD INCR is equivalent to ZINCRBY and adds the delta to whatever score already exists. Use plain ZADD GT when you want to track a best-score (fastest time, highest level reached).
The TTL approach keeps your Redis memory bounded without any cleanup job. Expired keys are collected lazily by Redis’s eviction mechanism. The buffer (25 hours for daily, 8 days for weekly) prevents keys from expiring while users might still be querying historical boards.
One operational note: set TTLs only once, not on every write. The pipeline.expire calls above reset the TTL to a fixed value on every score submission. For a daily key that was created 23 hours ago, this pushes the expiry out by 2 more hours. Depending on your requirements, you may prefer to set the TTL only on key creation using EXPIRE ... NX (set only if no TTL exists).
A subtler issue with time-windowed boards: what counts as “today” or “this week” depends on timezone. A board that resets at UTC midnight is correct for your backend but confusing for a user in UTC-8 who scores points at 11pm local time and finds their score in yesterday’s board. The standard choices are: use UTC everywhere and document it clearly, or use the user’s local timezone for display purposes while storing in UTC. The second option is implementable if you bucket keys by UTC day in the backend and compute the user-facing “current day” at read time in the API layer. Avoid storing separate leaderboard keys per timezone unless you have a very compelling reason; the memory and key count multiply by the number of timezones you support.
// Only set TTL on first creation; do not reset on every write
pipeline.expire(keys.daily, 25 * 60 * 60, "NX");
Tie-Breaking
When multiple users have the same score, display order is ambiguous. Redis breaks ties by lexicographic order of the member string. user:9 sorts before user:10 because "9" > "1" lexicographically, which is probably not what you want.
Production tie-breaking typically uses one of two approaches.
Timestamp-based composite scores: encode the score and the time of achievement into a single float. A user who reaches score 5000 earlier should rank above one who reaches it later.
// Encode: score * 1e10 + (max_timestamp - timestamp_ms)
// This puts higher scores first; for equal scores, earlier timestamp ranks higher
const MAX_TS = 9_999_999_999_999; // ms timestamp ceiling
function encodeScore(points: number, achievedAt: Date): number {
const tiebreaker = MAX_TS - achievedAt.getTime();
// Points occupy the high digits, tiebreaker occupies the low digits
// Works as long as points < 1e5 and tiebreaker < 1e13
return points * 1e13 + tiebreaker;
}
async function submitScoreWithTiebreak(userId: string, points: number): Promise<void> {
const encoded = encodeScore(points, new Date());
// Use GT: only update if the new encoded value is higher
// This means: higher points win; equal points, earlier time wins
await redis.zadd(BOARD_KEY, "GT", encoded, `user:${userId}`);
}
// Decode back to display score when reading
function decodeScore(encoded: number): { points: number; achievedAt: Date } {
const points = Math.floor(encoded / 1e13);
const tiebreaker = encoded % 1e13;
const timestampMs = MAX_TS - tiebreaker;
return { points, achievedAt: new Date(timestampMs) };
}
The tradeoff: floating-point precision limits how large your scores and timestamps can be. JavaScript numbers have 53 bits of mantissa, giving you 15-16 significant decimal digits. Plan your encoding range carefully. If points can exceed 10^5, the approach above needs adjustment.
Dedicated tie-break field in display data: the simpler alternative is to not encode tie-break into the Redis score at all. When fetching the top N, pull the score and member from Redis, then join against your database to retrieve achieved_at and sort by (score DESC, achieved_at ASC) in application code. This only works for bounded result sets (top 1,000 is fine; top 10,000,000 is not), but for paginated display pages it is practical and avoids the encoding complexity.
A third option that is often overlooked: accept ties explicitly in your product design. If two users both have 9,800 points and both display as rank 3, that is not a bug. It is often the correct behavior for casual games and social applications where exact tie-breaking carries no prize or consequence. Encoding tie-break logic into your score scheme adds real complexity; make sure that complexity is justified before you commit to it.
Sharding for Massive Scale
A single Redis sorted set starts to show memory pressure and CPU contention around 10-50 million members, depending on your key size and available memory. For larger scales, you shard.
The standard approach: divide the score range into N shards, each maintained as a separate sorted set. To find a user’s global rank, sum the member counts of all shards with a higher score range.
const SHARD_COUNT = 10;
function getShardKey(score: number): string {
// Shard by score range: assume max score is 10,000
// Shard 0: scores 0-999, Shard 1: 1000-1999, ..., Shard 9: 9000-9999
const shardIndex = Math.min(Math.floor(score / 1000), SHARD_COUNT - 1);
return `leaderboard:shard:${shardIndex}`;
}
async function submitScoreSharded(userId: string, score: number): Promise<void> {
const member = `user:${userId}`;
const shardKey = getShardKey(score);
// Remove from old shard if score changed; requires knowing the old score
const oldScore = await redis.zscore("leaderboard:scores", member);
const pipeline = redis.pipeline();
if (oldScore !== null) {
const oldShardKey = getShardKey(parseFloat(oldScore));
if (oldShardKey !== shardKey) {
pipeline.zrem(oldShardKey, member);
}
}
// Track current score separately for shard routing lookups
pipeline.hset("leaderboard:scores", member, score);
pipeline.zadd(shardKey, score, member);
await pipeline.exec();
}
async function getGlobalRankSharded(userId: string): Promise<number | null> {
const member = `user:${userId}`;
const scoreStr = await redis.hget("leaderboard:scores", member);
if (scoreStr === null) return null;
const score = parseFloat(scoreStr);
const shardIndex = Math.min(Math.floor(score / 1000), SHARD_COUNT - 1);
// Get rank within own shard
const rankWithinShard = await redis.zrevrank(getShardKey(shardIndex.toString()), member);
if (rankWithinShard === null) return null;
// Count all members in higher-scoring shards
const higherShardCounts = await Promise.all(
Array.from({ length: SHARD_COUNT - shardIndex - 1 }, (_, i) =>
redis.zcard(`leaderboard:shard:${shardIndex + i + 1}`)
)
);
const membersAbove = higherShardCounts.reduce((sum, count) => sum + (count as number), 0);
return membersAbove + rankWithinShard + 1;
}
The sharding tradeoff is complexity versus memory distribution. You can now spread the data across multiple Redis nodes. The cost is that cross-shard rank computation requires multiple round trips and application-level aggregation. You also need to handle score updates that move a user across shard boundaries (remove from old shard, insert into new shard), which is a two-step operation that requires either Lua scripting for atomicity or acceptance of a brief inconsistency window.
An alternative sharding strategy for user-partitioned boards (each user has their own pool, like a friends leaderboard): shard by user ID. Each shard contains a subset of users regardless of score, and rank is approximate. This avoids cross-shard rank aggregation at the cost of exact global rank.
Production Considerations
Memory estimation: a Redis sorted set entry uses roughly 64-80 bytes for the member string plus score plus skip list overhead. For 10 million users with member strings of 12 bytes, expect approximately 800MB per sorted set. For time-windowed boards (daily, weekly, monthly, all-time), multiply by four. Budget Redis memory accordingly and set maxmemory-policy noeviction for leaderboard data, since silent eviction of leaderboard entries would corrupt rankings without a visible error.
Persistence: Redis AOF with appendfsync everysec is sufficient for leaderboards where the score source of truth lives in your primary database. Leaderboard data can be rebuilt from the primary store on failure. If rebuilding is too expensive (hours of replay), use RDB snapshots plus AOF. Pure in-memory mode (no persistence) is not acceptable for leaderboard data you cannot cheaply reconstruct.
Read replicas for top-N reads: ZREVRANGE on large sets is not expensive, but under high read traffic you should route read-only operations (top-N, rank lookups, user score reads) to Redis read replicas. Writes (ZADD, ZINCRBY) must go to the primary. This is especially relevant for the all-time board, which is read on every profile page view.
Score submission rate limiting: without rate limiting, a single client can submit thousands of score updates per second, saturating the write path. Apply a per-user debounce or rate limit at the API layer. Score submissions that arrive within a 100ms window for the same user can be batched into a single ZADD.
Consistency with the primary store: Redis is the read layer for rankings, but the authoritative score should live in your primary database. Write to the database first, then update Redis. On Redis failure or cold start, your application needs a rebuild path: scan the scores table in batches and re-populate the sorted sets via ZADD. For 10 million users, this typically takes a few minutes at high pipeline throughput. Know this number before you need it. An emergency rebuild at 3am is not the time to discover it takes four hours.
Tradeoffs Table
| Approach | Rank lookup | Write cost | Scale ceiling | Complexity | Best for |
|---|---|---|---|---|---|
| SQL ORDER BY + index | O(N) COUNT scan | Low | ~1M rows before slowdown | Low | Small leaderboards, infrequent rank lookups |
| SQL materialized view | O(1) read | High (refresh cost) | Moderate | Medium | Boards where staleness is acceptable |
| Redis sorted set (single) | O(log N) | O(log N) | ~10-50M members/node | Low | Most production leaderboards |
| Redis sorted set (sharded) | O(log N) + shard count round trips | O(log N) | Effectively unlimited | High | 50M+ members, multiple Redis nodes |
| Approximate ranking (HyperLogLog + sampling) | O(1) approximate | O(1) | Unlimited | Medium | Percentile display only, no exact rank needed |
The Correct Default
For most leaderboard requirements up to 50 million users, a single Redis sorted set with time-windowed keys is the right architecture. The implementation is small, the operational footprint is limited to Redis (which you probably already have), and the performance characteristics are predictable.
Sharding adds non-trivial complexity and should only enter the design when you have concrete evidence that a single sorted set is insufficient. Premature sharding means you are maintaining shard routing logic, cross-shard rank aggregation, and shard rebalancing before you have proven you need any of it.
The decisions that actually matter in production are: GT versus INCR for score semantics, TTL management for windowed boards, tie-breaking strategy, and memory budgeting. Get those right and the leaderboard will run without incident for years.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.