YeetCode
System Design · Core Designs

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.

11 min readBy Bhavesh Singh
leaderboardredis sorted setsranking systemreal-timeshardinggaming backend

This article has an interactive companion

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode

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:

DimensionTarget
Daily active users100M
Score writes~50K/sec at peak
Top-K reads~200K/sec (cacheable)
Read latency (P99)< 10ms
Write latency (P99)< 5ms
ConsistencyReal-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:

text
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 rank

ZREVRANGE 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:

text
score_stored = real_score * 1e13 - last_update_ms

A 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.

ViewHow it's storedCost
GlobalThe primary ZSET, keyed by user_idSource of truth
RegionalA separate ZSET per region holding only that region's playersO(users in region), not O(users²)
FriendsComputed on readO(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.

StrategyWritesPer-user rankGlobal top-K
Hash-shard by user_idEven spreadO(log N) on one shardScatter-gather across all shards
Range-shard by scoreTop shard is a write hotspotWhich shard? depends on scoreSingle 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.

text
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 WebSocket

The 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:

text
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:

  1. Name the four operations and pick the sorted set with a one-line justification (O(log N), in-memory, sub-ms).
  2. Handle ties before being asked — bit-pack score and timestamp into one field.
  3. When pushed on scale, choose hash-sharding and volunteer the scatter-gather cost for top-K rather than pretending rank stays free.
  4. Separate the cacheable read (global top-K, seconds of staleness OK) from the fresh read (my own score, must be instant).
  5. 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

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.

Practice this topic on YeetCode