Vibe Engines
YouTube
System Design

Design a Leaderboard

Step 1 / 9

Learn system design by building a real-time leaderboard like a game ranking or Reddit hot list step by step.

The numbers to beatO(log N)update + rankO(log N + K)top-Kliveapply on every score

The whole design, in writing

Learn system design by building a real-time leaderboard like a game ranking or Reddit hot list step by step. An interactive guide covering why SQL ORDER BY + COUNT melts at scale, the sorted-set (Redis ZSET) that gives O(log N) rank and top-K, relative leaderboards around a user, sharding a global ranking, time-windowed boards with resets, a durable source of truth behind the in-memory index, and the unhappy paths (ties, approximate rank at huge N, anti-cheat, hot shards).

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What makes a leaderboard hard?

"Show the top 100" and "what's my rank?" sound trivial — until it's ten million players, scores change every second, and every player refreshes their rank constantly. Suddenly you're computing a global ordering over millions of rows, live, thousands of times a second.

Playersscore · rank · top
New in this step: Players.

A real-time leaderboard needs three operations to all be fast: update a score, get a player's rank, and fetch the top-K (or a window around a player). The whole design is about the right data structure — a sorted set — plus how to scale, window, and persist it.

What the new pieces do

Playersclient
Millions of players submitting scores and constantly asking "what's my rank?" and "show the top 100" — in real time.

Step 1 · The naive query

ORDER BY melts

The obvious approach: a scores table, and answer with SQL. Top-K is ORDER BY score DESC LIMIT 100; a player's rank is COUNT(*) WHERE score > mine. Why does this collapse at scale?

Playersscore · rank · top
The system as it stands at this step.

SQL ORDER BY for top-K and COUNT for rank. What's the problem at 10M players?

  1. SQL sorts fine; the issue is cost. Computing a global rank means counting how many scores beat yours — an O(N) operation — on every rank query, thousands of times a second, over constantly-changing data.

  2. The results are correct — they're just far too slow. Each rank query scans/counts a huge portion of the table, and scores change constantly, so you can't just cache the answer.

  3. A COUNT of all higher scores is linear in players, run on every "what's my rank?" and invalidated by every score change. At 10M players and high query rates it melts the database. You need a structure that keeps things sorted and answers rank in O(log N).

Rank via COUNT(*) WHERE score > mine is O(N) — you count everyone above you — run on every rank query and invalidated by every score change. At millions of players and thousands of queries per second it crushes the database. You need a structure that stays sorted and answers rank in O(log N).

Why this piece earns its place

The first instinct after seeing this is to keep the database and make rank cheap by storing it — a rank column, refreshed by a job. It’s worth pricing out loud, because it fails in a way that sounds reasonable in a meeting. A single score change doesn’t invalidate one rank; it shifts the rank of everyone between that player’s old position and their new one, so maintaining an exact rank column on write is the same linear cost you were trying to escape, moved to the write path — where it is now paid on every submission instead of every query. Refresh it on a schedule instead and rank stops being live: a player who just scored sees the old number until the job runs, which is precisely the moment they are staring at the screen. An index on score doesn’t rescue it either — it makes the top-K read cheap and leaves the count of everyone above you exactly as expensive. Rank has to be maintained incrementally by the structure, not recomputed or cached beside it.

Step 2 · The right shape

A service around a sorted structure

Put a leaderboard service in front, but the real question is what it stores behind it. You need one structure that makes update, rank, and top-K all fast at once. What data structure does that?

Playersscore · rank · topLeaderboard Svcrank · top-K
New in this step: Leaderboard Svc.

What single structure makes update, rank AND top-K all fast?

  1. A hash gives O(1) score lookup but no ordering — computing rank or top-K still requires scanning/sorting everyone. You need the scores kept in sorted order incrementally.

  2. A sorted set keeps players ordered by score with O(log N) insert/update and rank, and O(log N + K) range reads for top-K — exactly the three operations you need, all fast. Redis implements this as a ZSET (skip list + hash).

  3. Re-sorting an array of millions on every update or query is O(N log N) — worse than the SQL you're replacing. The structure must maintain sorted order as scores change.

Back the service with a sorted set — a structure (skip list or balanced tree + a hash) that keeps members ordered by score and supports O(log N) insert/update, O(log N) rank, and O(log N + K) range reads. Redis ZSET is the canonical implementation: ZADD to set a score, ZREVRANK for rank, ZREVRANGE for top-K. All three operations, all fast.

Why this piece earns its place

Notice the assumption this shape bakes in: that a player’s standing is one comparable number. A sorted set totally orders members by a single score, so anything the product ranks by has to be folded into that one value before it goes in. That’s fine while the rule is “highest score wins”, and it gets awkward the moment the rule is “highest score, then whoever got there first, then higher level” — each extra criterion has to live in the low-order part of the same number, and that number is a float, so the bits you spend on a tiebreak are bits the score itself no longer has. Encode too coarsely and the ties come straight back; encode too finely and a large score quietly loses its tiebreak to rounding. So I’d ask early what the ordering rule actually is, because a composite ordering is a decision about the key, and the key is the one thing that is expensive to change once millions of members are already stored under it.

What the new pieces do

Leaderboard Svcgateway
Applies score updates and answers rank / top-K / around-me queries by talking to the ranking index and store.

Step 3 · The three operations

Update, rank, top-K in O(log N)

Concretely: a player scores 5000, then asks "where am I?", and the UI wants the top 100. Trace how the sorted set answers each without ever scanning everyone.

Leaderboard Svcrank · top-KSorted SetRedis ZSET
New in this step: Sorted Set.

Update: ZADD board 5000 player inserts or moves the player in O(log N). Rank: ZREVRANK board player returns their position in O(log N) — the structure knows how many are above without counting them. Top-K: ZREVRANGE board 0 99 WITHSCORES reads the highest 100 in O(log N + K). Every leaderboard query is now logarithmic, not linear, and updates are cheap enough to apply live.

Why this piece earns its place

Two things here stay linear, and they are where this goes wrong under real traffic. The first is K. Top-K is O(log N + K), which is only cheap while K is small; an endpoint that lets a client ask for ten thousand rows in one go hands any caller the power to make one request cost about what the scan you just eliminated cost. Cap K at the service and never expose an unbounded range read. Where the slice starts is not the problem — a rank-indexed read seeks straight to any position in O(log N), which is exactly what the next step leans on to serve a player sitting deep in the tail — so it is the width of the window you hand back that needs the limit, not its depth. The second is that the whole board is one key. A key lives on one node, and that node runs commands for it one at a time, so every update and every rank query in the system queues behind every other one on a single core — O(log N) per command is a budget against that queue, not a free pass. Pipeline updates so a burst costs one round trip instead of thousands, and keep slow commands off that key entirely; one careless full-range read in a console stalls every player’s rank query while it runs.

  • O(log N)update + rank
  • O(log N + K)top-K
  • liveapply on every score

What the new pieces do

Sorted Setcache
The core: a Redis sorted set keyed by player, scored by score. O(log N) update and rank, O(log N + K) top-K.

Step 4 · "Players near me"

Relative leaderboards

Most players aren't in the global top 100 — they want to see themselves in context: the handful of players just above and below them, and their own rank. How do you serve a window around an arbitrary player cheaply?

PlayersLeaderboard SvcSorted Set
The system as it stands at this step. · swipe to pan the diagram

A player ranked 4,281,900 wants the players right around them. How?

  1. ZREVRANK gives the player's position in O(log N); ZREVRANGE over [rank-5, rank+5] reads the surrounding slice in O(log N + K). A relative leaderboard is just a rank lookup plus a small range read — both cheap.

  2. Pulling millions of entries to locate one player is exactly the linear cost you eliminated. Use rank + a bounded range read instead.

  3. Neighbors change on every nearby score update — precomputing and maintaining them is far more work than a live O(log N) rank + range read from the sorted set.

Compose two cheap ops: get the player's rank (ZREVRANK), then read the range around it (ZREVRANGE rank-5 rank+5). Both are O(log N)-ish, so a "players near me" view — the most-used leaderboard screen — is served instantly for anyone, anywhere in the ranking, without touching the millions of entries in between.

Why this piece earns its place

The catch in composing two reads is that they are two reads. Between the ZREVRANK and the ZREVRANGE, other players score, so the window comes back drawn around a position that may no longer be this player’s — and the most embarrassing version of that is a screen headed “you are here” that doesn’t contain the player at all. Do both in one atomic step (a script executed server-side), or take the player’s own row from the rank call and render that rather than trusting the range to hold them. The edges need clamping too: near the top, rank minus five is negative, and a negative index means something else entirely to a range read. And expect apparent movement with no cause. Among equal scores the order comes from whatever breaks the tie, so a player whose score hasn’t changed can still slide a place when someone ties them, and two refreshes a second apart can show different neighbours. Decide whether that’s acceptable before a support ticket decides it for you.

  • rankO(log N)
  • ± windowsmall range read
  • any playerinstant context

Step 5 · Beyond one node

Sharding a global ranking

One Redis instance holds a lot, but a truly massive game (or a single hot board) can exceed one node's memory or throughput. Sharding a leaderboard is harder than sharding a KV store, because rank is global — it depends on everyone. How do you distribute it?

Leaderboard Svcrank · top-KShard Arange / regionSorted Setsharded ZSETShard Brange / region
New in this step: Shard A, Shard B.

The board exceeds one node. Why is sharding a ranking uniquely hard?

  1. Hashing players across shards spreads storage, but then a global rank requires combining counts from every shard — you can't read one shard's local rank and call it global. Ranking has a cross-shard dependency a plain KV store doesn't.

  2. Replication scales reads but not size or write throughput of a single huge board, and every write still hits every replica. For very large boards you must partition, then reconcile global rank across partitions.

  3. Split the score space into ranges each owned by a shard (0–1k, 1k–10k, …), or shard by region. A player's global rank = their local rank plus the counts of all higher-range shards — so rank requires cross-shard aggregation, which is why huge boards often serve approximate/percentile ranks instead of exact ones.

Partition the ranking — commonly by score range (each shard owns a band of scores) or by region. Top-K within a shard is local; a global rank = the player's local rank plus the total counts of all higher-scoring shards, so it requires cross-shard aggregation. Because exact global rank over shards is expensive, massive leaderboards often serve an approximate or percentile rank ("top 3%") and keep exact rank only for the top tier.

Why this piece earns its place

Score-range shards quietly assume you know the shape of the score distribution and that it holds still. Neither is usually true. Scores pile up near the bottom, so bands cut evenly across the score space own wildly uneven populations — a band like 0–1k can hold most of the player base while the top band holds a handful — and the distribution drifts upward over a season as players get better, so a split that was balanced at launch is lopsided by the end. Worse, a player’s shard is a function of the very thing that keeps changing. Crossing a band boundary isn’t an in-place update, it’s a move between two nodes, and a move made of two operations can leave a player in both shards or in neither. Add to the new band before removing from the old, so the failure you get is a duplicate you can reconcile rather than a player who vanished from the board. Rebalance from measured population rather than from the score space, and treat the boundary bands as your hot ones — that is where the moves concentrate.

  • score-rangeor region shards
  • global rank= local + higher shards
  • approxpercentile at huge N

What the new pieces do

Shard Acache
One partition of a global ranking — a sorted set for a score range or region, so ranking scales past one node.
Shard Bcache
Another ranking partition. A global rank is assembled by combining per-shard ranks (or approximated).

Step 6 · Not one board but many

Time windows & resets

Players want a daily, weekly, and all-time board — and the daily one must reset cleanly at midnight without a giant blocking recompute. How do you run many time-boxed leaderboards?

Shard Arange / regionSorted Setsharded ZSETWindowed Boardsdaily · weekly
New in this step: Windowed Boards.

Keep a separate sorted set per window: board:daily:2026-07-11, board:weekly:2026-W28, board:alltime. A score update writes to all active windows. "Reset" is trivial — a new day is just a new key, and the old one expires via TTL (no blocking wipe). Rolling windows (last 24h) use time-bucketed sub-sets you union, or a decay function. All-time is the durable one; windowed boards are cheap and disposable.

Why this piece earns its place

The knob here is how many windows are live at once, and it multiplies the write path. Every active window is another write per score, so daily plus weekly plus all-time already triples the cost of one submission, and the moment someone asks for monthly, per-region and per-game-mode boards, a single score fans out into a dozen writes and a dozen resident sets in memory. TTL is the other half of the same knob: expire aggressively and you save memory but lose the “how did I do yesterday?” view players actually ask for; keep everything and old windows accumulate forever with nothing applying pressure to remove them. Set retention from what the product will display, not from what feels safe. And notice that the key names a date, which quietly makes a policy decision: midnight in whose timezone? Pick one canonical zone and say so in the UI — with per-player local resets, two friends comparing their daily boards aren’t even looking at the same board.

  • key per windowdaily/weekly/all-time
  • reset= new key + TTL
  • fan-outwrite to active windows

What the new pieces do

Windowed Boardsservice
Maintains time-boxed leaderboards (daily/weekly/all-time) as separate sorted sets that reset on schedule.

Step 7 · Don't lose the scores

Durability behind the index

Redis is fast because it's in-memory — but the leaderboard's scores are real player progress you must not lose, and score writes can arrive as a firehose. How do you get both speed and durability?

Score EventsLeaderboard SvcShard ASorted SetScores DBWindowed Boards
New in this step: Score Events, Scores DB. · swipe to pan the diagram

Treat the sorted set as a fast, rebuildable index and keep a durable database as the source of truth for scores. High-volume updates flow through a stream/queue, applied to both the ZSET (for instant ranking) and the DB (for durability) — reads always hit the in-memory index. If Redis is lost, rebuild the ZSET from the DB. Use Redis persistence (AOF) too, but the DB is the real backstop.

Why this piece earns its place

A queue in front of two stores means delivery is at-least-once, so the consumer has to be safe to replay — and that is a property of the update you choose, not of the queue. If an update is absolute (“this player’s score is now 5000”), applying it twice changes nothing, and an old message arriving late can be rejected on the spot by only accepting a higher value. If it’s an increment, a redelivered message silently inflates a player’s score and nothing anywhere reports an error; you then need an event id and a dedupe record, which is real state you now have to keep. Prefer absolute updates unless the product genuinely accumulates. The other thing to be honest about is that the rebuild is an operation, not a sentence in a design doc. Regenerating a board of ten million players out of the database takes time, and ranking is degraded throughout, so it should build into a fresh key and swap in when complete rather than serving a half-filled board — and it should be something you have actually run, because a recovery path nobody has exercised is a hope, not a backstop.

  • ZSETderived index
  • DBsource of truth
  • rebuildableregen index from DB

What the new pieces do

Score Eventsbus
A high-volume stream of score updates, decoupling a firehose of writes from the ranking index and durable store.
Scores DBdata
The durable record of every player's score. The ZSET is a fast derived index rebuilt from this if lost.

Step 8 · The sharp edges

Ties, huge N, and cheaters

Real leaderboards get gamed and get messy: two players with the same score need a deterministic order, exact rank is meaningless for player #4,281,900, and someone will submit a score of 2 billion.

PlayersScore EventsLeaderboard SvcShard ASorted SetShard BScores DBWindowed Boards
The system as it stands at this step. · swipe to pan the diagram

Break ties deterministically — e.g. earlier-achieved wins, by encoding a timestamp into the score's fractional part so order is stable. For the long tail, serve approximate rank / percentile from a score histogram (O(1)) instead of an exact global position no one needs. Stop cheating with server-side score validation, rate limits, anomaly detection, and signed submissions — never trust a client-reported score. And spread hot shards (a viral board) with replicas and read caching of the top-K.

Why this piece earns its place

Detection is asynchronous, so the interesting half of anti-cheat isn’t blocking the bad score — it’s withdrawing one you already accepted and already showed everyone. That is a design requirement, and it lands on decisions you’ve already made. The cheated entry sits in the all-time set and in every windowed set it touched, so the removal has to fan out exactly the way the write did, and anything served from it — a cached top page, a screenshot someone shared — is downstream of the mistake. Pulling the player out also shifts everyone below them by one, which is the correct outcome and still reads as a bug to the person it happens to, so say it in the UI rather than letting them discover it. Removal shouldn’t be a plain delete either: keep the ban in the durable record, or the next submission from that account walks straight back onto the board. And be careful how hard the rate limit bites — an exceptional legitimate run and a script don’t look that different, so the limit should route a suspect submission into review rather than reject it outright.

You did it

You just designed a leaderboard.

PlayersScore EventsLeaderboard SvcShard ASorted SetShard BScores DBWindowed Boards
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • SQL rank is O(N) (count everyone above) — it melts at millions of players and high query rates.
  • A sorted set gives O(log N) update + rank and O(log N + K) top-K — all three fast at once.
  • Redis ZSET: ZADD to update, ZREVRANK for rank, ZREVRANGE for top-K.
  • Relative "around me" boards = rank lookup + a small range read, instant for any player.
  • Sharding is hard because rank is global — partition by score range/region and approximate the tail.
  • A sorted set per time window makes daily/weekly/all-time boards and clean resets trivial.
  • Keep a durable DB as source of truth; the ZSET is a fast, rebuildable index. Guard ties, scale, cheats.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why is "what's my rank?" the operation that makes leaderboards hard?

    Top-K is actually easy — any ordered index answers "highest 100" with a bounded read. The expensive operation is rank, because a player's global rank is a fact about the entire population: it's the number of players scoring higher than them. Computed naively in SQL that's COUNT(*) WHERE score > mine, an O(N) scan/count run on every rank query and invalidated by every score change, so at millions of players and thousands of queries per second it saturates the database. The whole design pivots on making rank cheap: you need a structure that maintains sorted order incrementally and can report how many members are above a given one without counting them, which a sorted set (skip list annotated with span/subtree sizes) does in O(log N). Once rank is logarithmic, updates and top-K are easy too.

  2. How does a Redis sorted set make all three operations fast simultaneously?

    A ZSET is really two structures working together: a hash map from member to score, and a skip list of members ordered by score. The hash gives O(1) "does this player exist and what's their score," which makes updates cheap because you can locate a member instantly. The skip list keeps everyone in sorted order and is augmented so that each forward pointer records how many nodes it spans; summing those spans along the search path yields a member's rank in O(log N) without visiting every node, and reading a contiguous slice for top-K or an around-me window is O(log N + K). So a single structure delivers O(log N) insert/update (ZADD), O(log N) rank (ZREVRANK), and O(log N + K) range reads (ZREVRANGE) — exactly the three leaderboard primitives — and because it maintains order incrementally it never pays to re-sort.

  3. Why is sharding a leaderboard harder than sharding a key-value store?

    A key-value store shards cleanly because keys are independent: to serve a key you only need the shard that owns it, and nothing about one key depends on another. A rank is the opposite — it's inherently a property of the whole population, so a player's global position depends on every other player's score, which may live on other shards. If you partition by score range (shard A owns 0–1k, B owns 1k–10k, and so on), top-K within a band is local, but a player's global rank is their local rank plus the total member counts of all higher-scoring shards, which requires aggregating across shards on every rank query. That cross-shard dependency is why very large leaderboards usually stop serving exact global rank for everyone: they keep exact rank only for a small hot top tier and serve approximate or percentile ranks ("you're in the top 3%") for the massive tail, often computed in O(1) from a maintained histogram of score→count, trading exactness for scale.

  4. Why keep a separate database when Redis already holds the ranking?

    Because the two serve different masters. Redis holds the sorted set in memory, which is what makes ranking fast, but in-memory data is the wrong place to be the sole authority for something you must never lose — real player progress and scores. So you treat the ZSET as a derived index optimized for the read pattern, and keep a durable database as the source of truth for scores. Writes are applied to both (often via a stream/queue so a firehose of updates is decoupled and reliable), reads always hit the fast in-memory index, and if Redis is ever lost you rebuild the entire ranking from the database. This decoupling gives you all three properties at once: fast reads from memory, guaranteed durability in the DB, and a disposable, regenerable index. Redis persistence (AOF/RDB) is a useful speed-up for recovery, but the database is the real backstop.

  5. How do you handle ties and stop players from cheating their score?

    Ties need a deterministic, stable tiebreak so two equal scores always order the same way and pagination doesn't shuffle; a common trick is to encode a secondary signal into the score itself — for example, fold a timestamp into the fractional part so that among equal integer scores the one achieved earlier ranks higher, giving a single composite key the sorted set can order unambiguously. Cheating is a separate, unavoidable concern because the client can't be trusted: never accept a client-reported score at face value. You validate scores server-side against what's actually achievable, rate-limit submissions, run anomaly detection to flag impossible jumps, sign or authenticate submissions so they can't be forged, and reconcile against server-authoritative game state where possible. Combined with the durable source-of-truth database (so you can audit and roll back fraudulent entries) and caching/replication for hot boards, these guards keep the elegant sorted-set core trustworthy under real, adversarial players.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. The operation that makes leaderboards hard is…

    Rank counts everyone above you; naive SQL is O(N) per query and melts at scale.

  2. A sorted set (Redis ZSET) gives…

    Skip list + hash maintain order incrementally, so all three ops are cheap at once.

  3. A "players near me" view is served by…

    ZREVRANK then ZREVRANGE over [rank±k] — both cheap, instant for any player.

  4. Sharding a leaderboard is hard because…

    Partition by score range/region; global rank = local + higher shards, so huge boards approximate.

  5. The durable database matters because…

    Reads hit memory; durability lives in the DB; the ranking index can be regenerated from it.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Update: set or bump a player’s score (ZADD).
  • Rank: answer "what’s my rank?" for any player (ZREVRANK).
  • Top-K: fetch the top 100 (ZREVRANGE).
  • Around me: show the players just above and below a player.
  • Windows: daily, weekly and all-time boards with clean resets.

The qualities that shape everything

Each one names the mechanism that buys it.

Update, rank and top-K all fast at once
Back the service with a sorted set (Redis ZSET) — a skip list + hash giving O(log N) update/rank and O(log N + K) top-K, maintaining order incrementally so it never re-sorts.
Instant "players near me" for anyone
Compose a rank lookup (ZREVRANK) with a small range read (ZREVRANGE rank±5) — both O(log N)-ish — so the most-used screen skips the millions of entries in between.
Scale a board past one node
Partition by score range or region; a global rank is a shard’s local rank plus the counts of higher shards, and huge boards serve approximate/percentile rank from a histogram.
Daily/weekly/all-time boards, clean resets
Keep a separate sorted set per window keyed by date; a reset is just a new key while the old one TTLs away — no blocking recompute.
In-memory speed without losing scores
Treat the ZSET as a fast, rebuildable index and keep a durable database as the source of truth; updates flow through a queue to both, and the ZSET regenerates from the DB if lost.
Deterministic order and cheat-resistance
Break ties by folding a timestamp into the score, validate scores server-side with rate limits and anomaly detection, and replicate + cache top-K for hot boards.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A sorted-set index over SQL ORDER BY + COUNT

Rank via COUNT(*) WHERE score > mine is O(N), run on every query and invalidated by every score change — it melts the DB at millions of players. A sorted set keeps order incrementally and answers rank in O(log N).

A sorted set (skip list + hash) over a hash map of player → score

A hash gives O(1) score lookup but no ordering, so rank and top-K still need a full scan/sort. A sorted set keeps players ordered by score, making all three operations logarithmic at once.

Rank + a bounded range read over fetching the whole board to find them

Pulling millions of entries to locate one player is exactly the linear cost you eliminated. ZREVRANK then a small ZREVRANGE around it serves any player’s context instantly.

Score-range shards, approximate tail over hashing players across shards

Hashing players spreads storage but a global rank still needs counts from every shard. Partitioning by score band makes rank = local rank + higher shards, and huge boards serve percentile rank rather than pay exact cross-shard aggregation.

Derived index + durable DB over Redis as the sole store

In-memory data is the wrong sole authority for real player progress. Keep the ZSET as a rebuildable read-optimized index and a durable database as the source of truth — reads stay fast, durability is guaranteed, and the ranking regenerates from the DB.

The answer, out loud

What a strong answer to “Design a Leaderboard” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    Scope the board first

    Let me scope it before I draw anything. A leaderboard has to do four things: take a score, tell a player their rank, hand back the top hundred, and show a player the people right around them. The read side dwarfs the write side — everybody refreshes their own rank constantly — and I’d ask up front whether we need daily and weekly boards or only all-time, because that changes the shape of this more than the traffic does. I’d also flag which of those four is actually hard, because it isn’t the one people expect.

  2. 4–9 min

    Rank is the expensive one

    The hard one is rank. Top hundred is easy — any ordered index gives you the highest rows with a bounded read. Rank is a fact about the whole population: your position is the number of people above you. In a database that’s a count of everyone with a higher score, run on every single rank request, and it goes stale the instant anyone anywhere scores. At ten million players, with all of them refreshing, that’s the query that takes the database down. So the whole design is really an answer to one question: how do I know how many people are above someone without counting them?

    Built in step 1: ORDER BY melts
  3. 9–14 min

    Keep everyone in order instead

    The answer is to keep everyone in order all the time rather than working it out on demand. A hash of player to score is instant to look up and useless for ranking, because it has no order. Sorting an array on every query is worse than the SQL I just threw away. What I want is a sorted set — a skip list ordered by score with a hash beside it — which is what Redis calls a ZSET. It maintains order as scores change, so it never re-sorts, and it can report a member’s position by walking down the list rather than across the whole population.

    Built in step 2: A service around a sorted structure
  4. 14–19 min

    The three calls

    Then the three operations fall out of the structure. Setting a score is a ZADD, which inserts or moves the player in log N. Rank is a ZREVRANK, also log N. The top hundred is a ZREVRANGE from zero to ninety-nine — log N to find the start, plus the rows you actually wanted. So the query that was going to kill us is now logarithmic, and updates are cheap enough to apply live instead of batching, which matters, because a leaderboard that visibly lags is one people stop believing.

    Built in step 3: Update, rank, top-K in O(log N)
  5. 19–24 min

    The screen people actually open

    I’d spend a minute on the view most players actually look at, which isn’t the top hundred — it’s themselves. Someone sitting millions of places down doesn’t care who is first; they care about the handful of players just above them. That view is the two calls composed: get their rank, then read the small slice around it. Both are cheap, and I never touch the millions of entries in between. Honestly this is a stronger argument for the structure than top-K is, because it turns “show this one person in context” from a scan into two lookups.

    Built in step 4: Relative leaderboards
  6. 24–30 min

    Sharding, and why it’s different here

    Scaling past one node is where a leaderboard stops behaving like a normal service. I can’t just hash players across shards, because rank isn’t a property of a player, it’s a property of everyone — a local position on one shard means nothing globally. So I’d partition by score band, or by region if the product allows it, and then a global rank is a player’s local rank plus the total counts of every higher band. That’s cross-shard work on the read path I just made fast, which is why very large boards tend to keep exact rank for a small top tier and serve the tail a percentile instead — top three percent, rather than a precise position nobody needs.

    Built in step 5: Sharding a global ranking
  7. 30–35 min

    Daily and weekly boards

    For time windows I’d use a key per window — a separate sorted set named by the day or the week, alongside the all-time one — and a score update writes into whichever windows are live. The nice part is the reset: midnight doesn’t trigger a recompute or a wipe, it just means new writes land on a new key, and yesterday’s set expires on its own. Rolling windows are harder, since you either bucket by hour and union them or decay the scores, so I’d push back on one unless the product really needs it.

    Built in step 6: Time windows & resets
  8. 35–40 min

    Speed in memory, truth on disk

    Redis being in memory is the entire reason this is fast, and it’s also why I wouldn’t let it be the only copy — these are real player achievements. So the sorted set is a derived index and a durable database is the source of truth. Updates arrive on a stream, get applied to both, and reads only ever touch the index. If we lose the index we rebuild it from the database and accept slow ranking meanwhile; if the database stalls, reads keep flying and writes buffer until it’s back. Those two failures are deliberately decoupled.

    Built in step 7: Durability behind the index
  9. 40–45 min

    Sharp edges and the trade-off

    The rough edges I’d name before we run out of time: ties need a deterministic tiebreak folded into the score or pagination shuffles under people; enormous boards should serve a percentile rather than pretend an exact position is meaningful; and no score a client reports gets trusted — validate server-side, rate-limit, and watch for jumps that aren’t physically achievable. The trade-off underneath all of it is exactness against scale: everything I gained came from not computing the true global answer on demand, and sharding gives up a little more. And I’d say plainly: pulling a cheated score back out is a fan-out that has to mirror the write — the all-time set and every window it landed in. With more time I’d put a number on memory per player so we could size the shards.

    Built in step 8: Ties, huge N, and cheaters

What this teaches

Learn system design by building a real-time leaderboard like a game ranking or Reddit hot list step by step. An interactive guide covering why SQL ORDER BY + COUNT melts at scale, the sorted-set (Redis ZSET) that gives O(log N) rank and top-K, relative leaderboards around a user, sharding a global ranking, time-windowed boards with resets, a durable source of truth behind the in-memory index, and the unhappy paths (ties, approximate rank at huge N, anti-cheat, hot shards).

Key takeaways

  • SQL rank is O(N) (count everyone above) — it melts at millions of players and high query rates.
  • A sorted set gives O(log N) update + rank and O(log N + K) top-K — all three fast at once.
  • Redis ZSET: ZADD to update, ZREVRANK for rank, ZREVRANGE for top-K.
  • Relative "around me" boards = rank lookup + a small range read, instant for any player.
  • Sharding is hard because rank is global — partition by score range/region and approximate the tail.
  • A sorted set per time window makes daily/weekly/all-time boards and clean resets trivial.
  • Keep a durable DB as source of truth; the ZSET is a fast, rebuildable index. Guard ties, scale, cheats.

Concepts covered

  • What makes a leaderboard hard?
  • ORDER BY melts
  • A service around a sorted structure
  • Update, rank, top-K in O(log N)
  • Relative leaderboards
  • Sharding a global ranking
  • Time windows & resets
  • Durability behind the index
  • Ties, huge N, and cheaters
built to answer "what's my rank?" for ten million players in a blink — make the calls, drop the index, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs