Design a Leaderboard: Real-Time Ranking at Scale
How to design a real-time leaderboard with Redis sorted sets — score updates, top-K, rank queries, tie-breaking, sharding, and durability, with concrete numbers.
This article has an interactive companion
Don't just read it — step through it interactively, one state change at a time.
A leaderboard looks trivial until someone asks for a player's exact global rank while 100 million users are all posting scores at once. The naive version — a table with a score column and ORDER BY score DESC LIMIT 10 — works fine for a hackathon and falls over the instant the table gets large, because computing one player's rank means counting every row that beats them.
The real problem is that a leaderboard mixes two access patterns that pull in opposite directions: fast writes (millions of score updates per minute) and ranked reads (top-K lists, plus "where am I?" rank lookups). The design that wins interviews and survives production leans on one data structure — the Redis sorted set — and then spends the rest of the discussion on what breaks when a single instance is no longer enough.
This guide walks the full arc: the core operations, tie-breaking, the three flavors of leaderboard (global, regional, friends), sharding, exact-rank queries, and durability. You can also practice this topic on YeetCode alongside the rest of the Core Designs track.
What the system actually has to do
Pin down the operations before reaching for components. A game leaderboard has four:
- Submit a score — a player finishes a match; their score changes (set to a new value, or incremented by a delta).
- Get the top K — render the top 10, 100, or 1000 for the home screen.
- Get a player's rank — "you are #4,812 of 12M", shown on the profile.
- Get a window around a player — the five people directly above and below you, which is what actually drives engagement.
For non-functional targets, put real numbers on the table so the trade-offs have teeth:
| Dimension | Target |
|---|---|
| Daily active users | 100M |
| Score writes | ~50K/sec at peak |
| Top-K reads | ~200K/sec (cacheable) |
| Read latency (P99) | < 10ms |
| Write latency (P99) | < 5ms |
| Consistency | Real-time for the writer; a few seconds of lag on global top-K is fine |
That last row is the most important design lever. Nobody notices if the global top-100 is two seconds stale, but a player expects their own score to update instantly. That asymmetry is what makes caching and debounced aggregation safe later.
Why Redis sorted sets are the default
A relational table forces a full scan or an index walk to answer "how many scores beat X". A Redis sorted set (ZSET) is purpose-built for exactly this. It stores member -> score pairs kept in score order using a skiplist plus a hash map, which gives O(log N) inserts and O(log N + M) range reads. Everything lives in memory, so P99 latency sits comfortably under a millisecond.
The four operations map to four commands:
ZADD board 4820 "user:42" # set an absolute score
ZINCRBY board 150 "user:42" # apply a delta (+150)
ZREVRANGE board 0 9 WITHSCORES # top 10, highest first
ZREVRANK board "user:42" # this user's 0-based rankZREVRANGE 0 9 returns the top ten in O(log N + 10). ZREVRANK answers "what's my rank?" in O(log N) — the operation a SQL COUNT(*) WHERE score > ... turns into a full index scan. For the window-around-a-player screen, read the rank with ZREVRANK, then ZREVRANGE rank-5 rank+5. Two O(log N) calls and you've rendered the most engaging view in the product.
How do you break ties deterministically?
Two players with 4,820 points need a stable order, and Redis does not promise anything useful for score ties beyond raw byte ordering of the member string. Relying on that gives you a rank that flickers between page loads, which looks like a bug.
Encode the tiebreaker into the score itself. Reserve the high bits for the real score and the low bits for an inverted timestamp, so an earlier achiever outranks a later one at the same score:
score_stored = real_score * 1e13 - last_update_msA 64-bit double holds this comfortably for realistic score ranges. Because the stored value is still a single monotonic number, the ZSET stays correctly ordered with zero extra structures — one field does both jobs. The "earlier wins" convention is the standard one; flip the sign on the timestamp term if you want "most recent wins" instead.
Global, regional, and friend leaderboards without 3x the data
Products rarely ship one leaderboard. You want global, per-region, and per-friend-group views. Storing three independent copies triples memory and creates three things to keep consistent. Derive instead.
| View | How it's stored | Cost |
|---|---|---|
| Global | The primary ZSET, keyed by user_id | Source of truth |
| Regional | A separate ZSET per region holding only that region's players | O(users in region), not O(users²) |
| Friends | Computed on read | O(friends), bounded |
Regional boards are cheap because each player appears in exactly one region ZSET, so the total extra memory across all regions equals one more full copy at most, not a copy per region. Friend leaderboards should not be materialized per user — that would cost O(users × avg_friends) and update on every score change across every friend list. Instead, compute them on read: take the player's friend list (a few hundred entries at most), call ZMSCORE on the primary ZSET to fetch all their scores in one round trip, and sort those few hundred numbers client-side. The friend view is always fresh and costs nothing to maintain.
What happens when one Redis instance isn't enough?
A single ZSET of 500M players eventually exceeds one machine's memory. Sharding is where the interesting trade-offs live, and the sharding key decides which queries stay cheap.
| Strategy | Writes | Per-user rank | Global top-K |
|---|---|---|---|
Hash-shard by user_id | Even spread | O(log N) on one shard | Scatter-gather across all shards |
| Range-shard by score | Top shard is a write hotspot | Which shard? depends on score | Single ZREVRANGE on the top shard |
Hash-sharding by user_id spreads writes evenly and keeps every per-user operation (ZINCRBY, self ZREVRANK) on a single shard. The cost is that global top-K now needs a scatter-gather: query the top-K from each shard, merge the results in the client, and return the overall top-K. This is correct because any member of the global top-K must be in the top-K of its own shard — a player who isn't top-K locally can't be top-K globally.
Range-sharding by score makes global top-K a single read on the highest shard, but that shard becomes a write hotspot every time a high scorer updates, and rebalancing gets ugly when the score distribution drifts over a season. Most systems hash-shard and then cache a materialized top-K view refreshed every few seconds, which sidesteps the hotspot entirely.
Real-time global top-100 across 50 shards
Combine the pieces for the hard version: a global top-100 that updates in near real-time for 100M DAU spread across 50 Redis shards. Running a 50-way scatter-gather on every one of 200K reads per second would hammer every shard constantly. Invert the flow.
Each shard maintains its local ZSET and, on update (debounced to avoid a flood), publishes its current top-200 to a central aggregator over pub/sub or a stream. The aggregator merges 50 × 200 = 10,000 candidates into one ZSET and recomputes the global top-100 roughly once a second, caching the result behind the API.
shard[i] --top-200 (debounced)--> aggregator
aggregator: merge 10K candidates -> global top-100 (every ~1s)
API: serve cached top-100 from edge; push diffs over WebSocketThe top-200 (rather than top-100) per shard is deliberate headroom: it covers the window where a shard's #101 briefly overtakes a stale global #100 before the next refresh. Serve the cached list from a CDN or edge cache and push incremental diffs over WebSocket so clients see live movement without re-fetching the whole list. Reads no longer touch the shards at all.
How do you return an exact global rank?
Top-K is easy to cache; a specific player's exact global rank is not, because it depends on every score above theirs. Under hash-sharding, exact rank is fundamentally O(shards × log N_local): ask each shard "how many of your players beat this score?" with ZCOUNT(myscore, +inf) and sum the answers. Across 50 shards those are parallel O(log N) calls that total well under 10ms, which is usually fine.
When even that is too slow, precompute a per-shard score histogram — buckets of, say, 100 score points each. Then a rank is:
rank = sum(shard_histogram[bucket > mine]) + local_ZCOUNT(mine, +inf)The fan-out becomes a handful of cheap counter reads plus one local count. For players outside the top 10K — where nobody cares about the exact number — fall back to a percentile estimate straight from the histogram ("top 12%") and skip the fan-out entirely.
Durability: Redis is a view, not the truth
Redis is in-memory. Treating it as your system of record means one crash can vaporize the season. Treat it as a materialized view instead. Write every score event to a durable log first — Kafka or an append-only table — and apply it to Redis through a consumer.
If Redis dies, rebuild the ZSET by replaying the log from the last snapshot checkpoint. Redis AOF and RDB snapshots give you a fast recovery path; the event log is the correctness backstop behind it. Tag each event with a monotonic event_id per user so the consumer is idempotent: ignore any event whose id is less than or equal to the last applied one. That single rule makes consumer restarts and log replays safe — a score can never be double-applied or regress.
How this shows up in interviews
Interviewers use the leaderboard as a compact test of whether you reach for the right primitive and then reason about scale honestly. A strong answer moves through predictable checkpoints:
- Name the four operations and pick the sorted set with a one-line justification (O(log N), in-memory, sub-ms).
- Handle ties before being asked — bit-pack score and timestamp into one field.
- When pushed on scale, choose hash-sharding and volunteer the scatter-gather cost for top-K rather than pretending rank stays free.
- Separate the cacheable read (global top-K, seconds of staleness OK) from the fresh read (my own score, must be instant).
- Close on durability: Redis as a view, event log as the truth, idempotent consumer.
The common failure is stopping at "use a Redis sorted set" and treating the follow-ups as trivia. They aren't — sharding, exact rank, and durability are the whole point, and they're the same distributed-systems muscles you'll use everywhere else.
Further learning
- Real-time Leaderboard with Azure SQL & Redis — Microsoft Developer walks through the write-through-log-plus-Redis-view pattern with working code.
- Design a Key-Value Store — the sharding, replication, and consistency machinery underneath any Redis-backed design.
- Design a Rate Limiter — another counter-heavy problem that lives or dies on the right in-memory data structure.
- Design a URL Shortener — the canonical warm-up for read-heavy systems with hot-key caching.
- Design a Notification System — pairs naturally with the WebSocket push layer that streams live rank changes.
FAQ
Why not just use a SQL database with ORDER BY score?
An indexed ORDER BY score DESC LIMIT 10 handles the top-K fine, but a single player's rank query — SELECT COUNT(*) WHERE score > mine — scans every row above them, which degrades as the table grows and gets worse under write load. A Redis sorted set answers the same rank query in O(log N) with ZREVRANK because it keeps members ordered in a skiplist, and it does it in memory at sub-millisecond latency. SQL is a fine durable backing store for the events, but it's the wrong engine for the live ranking reads.
How do you keep ranks stable when two players tie?
Fold the tiebreaker into the stored score so ordering stays a single-number comparison. Multiply the real score up (for example score * 1e13) and subtract the last-update timestamp in milliseconds, which makes the earlier achiever rank higher at equal scores. Redis then orders the sorted set correctly with no extra data structure and no reliance on member string ordering, which it doesn't guarantee for ties. This is far cheaper and more predictable than a secondary sort at read time.
What's the cost of getting the global top-K when the data is sharded?
With hash-sharding by user_id, global top-K becomes a scatter-gather: you query the top-K from each shard and merge them client-side, which is correct because a global top-K member must be top-K on its own shard. Doing that on every read is wasteful at 200K reads/sec, so production systems run the merge on a central aggregator once every second or so and serve the cached result from an edge cache. Reads then hit the cache instead of fanning out to every shard.
Can you get a player's exact global rank across shards efficiently?
Exact global rank under sharding is inherently O(shards × log N_local) — you ask each shard how many players beat the given score with ZCOUNT(score, +inf) and sum the results, which across ~50 shards runs in parallel and totals under 10ms. For tighter latency, precompute per-shard score histograms so rank becomes a few counter additions plus one local count. Outside the top 10K, return an approximate percentile from the histogram, since exact placement stops mattering to the player.
How do you avoid losing the leaderboard when Redis restarts?
Never treat Redis as the source of truth. Append every score event to a durable log (Kafka or an append-only table) before applying it to Redis through a consumer, so the ledger survives any in-memory failure. On a crash, replay the log from the last snapshot checkpoint to rebuild the sorted set, using Redis AOF/RDB as a faster recovery path. Give each event a monotonic per-user id and have the consumer ignore ids it has already applied, which makes replays and restarts idempotent and prevents double-counting.
Make it stick: run this one yourself
Don't just read it — step through it interactively, one state change at a time.