Scale Interview
System DesignMedium

Design a Real-Time Gaming Leaderboard

Ranking System Design

Overview

A leaderboard ranks players by score and answers, instantly, "where do I stand?" A player finishes a match, their score updates, and the moment they open the leaderboard they expect to see their global rank ("you are #14,238 of 40 million"), the top of the board (the top 100), the players just above and below them ("around me"), and often how they compare to their friends. All of this while millions of scores change every minute.

The product sounds like "sort a list," but sorting a list of 40 million players on every request is hopeless, and the interesting part is the one query a naive design cannot do cheaply: given a score, what rank is it? A plain database can store the scores easily, but asking "how many players have a higher score than me?" forces it to count a huge number of rows on every read. It is labeled "Medium" because the right answer hinges on picking one data structure — a sorted set — that makes update, rank, and range all fast, and then scaling it to hundreds of millions of players without a single hot spot.

In this walkthrough you'll scope the system, size it, pick a data model, design the API, draw the write and read paths, then go deep on why a sorted set is the right structure, how to shard and cache it for a huge read-heavy board, and how to serve the rank queries (global rank, top-N, around-me, and friends).

Out of scope (say so): matchmaking, anti-cheat / score validation, the game servers that produce scores, and the social graph itself (we consume a friend list, we don't build it). We own the score → rank pipeline and the queries over it.

Functional requirements

  • Submit a score. A player's score updates after a match. State the semantics up front: is the leaderboard highest score wins (keep the best score ever) or latest score (overwrite with the newest)? We assume highest-score-wins, so a submission only raises a player's stored score, never lowers it.
  • Get a player's rank. Given a player, return their global rank (1 = highest score) and their score.
  • Get the top N. Return the top N players in order (e.g. the top 100), with scores.
  • Get "around me". Return the k players immediately above and below a given player, so they can see who to overtake.
  • Friends leaderboard. Rank a player among their friends only — a small set (tens to low hundreds) drawn from an external friend list.
  • Time windows. Support daily / weekly / all-time boards (the same queries, over different time ranges).

Out of scope (state it): matchmaking, cheat detection, and the game simulation that produces scores. This system turns a stream of score events into a live ranking and serves rank queries over it.

Non-functional requirements

  • Read-heavy. Far more players view the leaderboard than submit scores at any instant, and the same hot pages (top-N, "my rank") are read constantly. Reads must be cheap and cached.
  • Real-time-ish updates. A player's own score update should feel instant to them. The global rank may lag a few seconds — see the consistency point below.
  • Fast rank queries. Get-rank, top-N, and around-me must all be fast (sub-millisecond to low-millisecond) even with tens of millions of players on the board.
  • Eventual consistency is acceptable for rank. A global rank that is a few seconds stale is fine — a player ranked 4 million does not notice, and even near the top a brief lag is acceptable. Eventual consistency is the norm; the player's own score change is what must feel immediate.
  • Durable scores. The ranking structure lives in memory for speed, so the authoritative score must also live in a durable store. The in-memory board is a rebuildable cache, not the source of truth.
  • Availability over strict correctness. A rank read that fails should degrade to a slightly stale value, not an error.

Estimations

State assumptions; the goal is to justify the data structure and the sharding, not to be exact.

Rounded assumptions

  • Players on a board: ~40M active players in an all-time board (hundreds of millions registered, tens of millions ranked in a given window).
  • Score submissions/sec. Say 40M active players play a few matches a day: ~100M score events/day ÷ 86,400 s ≈ ~1K writes/sec average, with peaks of several times that during events.
  • Rank reads/sec. Every leaderboard open is one or more rank queries (my rank + top-N + around-me), and players open it far more than they submit: easily ~100K reads/sec, two orders of magnitude above writes.
  • Board size in memory. Each ranked entry is roughly a player id + a score + index overhead. In real Redis a ZSET member costs a skip-list node, a dict entry, and the member string (SDS) plus allocator fragmentation, so budget ~150–200 bytes per entry for string ids — not the optimistic ~100. 40M players ≈ ~6–8 GB per board. Integer-encoded member ids are the biggest lever to shrink this. The point: it does not comfortably fit one node, which only strengthens the case for sharding.
  • Hot page. The top-N (first page) and each player's "my rank" are the overwhelmingly common reads. The top-N changes slowly (the same names sit at the top for minutes), so it is highly cacheable.

How the numbers affect the design

Signal from the numbers Design decision
Rank reads ≫ writes Serve rank/top-N/around-me from an in-memory structure and cache the hot pages; never sort a table per read.
"What rank is this score?" on every read A plain ORDER BY score + COUNT(*) is O(rank) per read — it walks and counts every higher-scoring row (cheap near the top, up to O(N) for a mid-ranked player) — use a sorted set that gives rank in O(log N).
~6–8 GB per board, and it must survive a restart Keep the sorted set in memory for speed but store authoritative scores durably; the board is a rebuildable cache.
One global board of 40M+ players A single in-memory node is a memory and hot-key risk — plan replicas for reads and sharding for size/writes.
Rank can be a few seconds stale Update the board asynchronously and cache the top-N; only the player's own score change must feel instant.

Warning

Do not plan to compute rank with SELECT COUNT(*) WHERE score > my_score on every read. With ~100K rank reads/sec over 40M rows, counting rows per read is impossibly expensive — rank must come from a structure that maintains order, not a per-read count.

Core entities / data model

Two things are stored: the authoritative score (the durable source of truth for each player's score) and the ranking structure (a derived, in-memory sorted set that makes rank and range queries fast).

Entity Key fields
PlayerScore player_idscore, updated_at — the durable record of a player's best score
Leaderboard a sorted set: member = player_id, ordering key = score (with a tie-break)

The PlayerScore row is the single source of truth. It lives in a durable store (SQL or a key-value store) so a score survives a crash or a full rebuild of the in-memory board.

The Leaderboard is a sorted set — a structure that keeps players ordered by score and can answer "insert this player with this score," "what rank is this player," and "give me players ranked X to Y" all in logarithmic time (in Redis, a ZSET, but the structure is the point, not the product). It is derived from the authoritative scores: if it is lost, it is rebuilt by replaying PlayerScore rows back into the sorted set.

Caution

Do not treat the in-memory sorted set as the source of truth. It is a fast, rebuildable index of the scores. The durable PlayerScore store is authoritative — if you keep only the sorted set and a node dies, you lose every score and cannot recover the board.

Tie-breaking. Two players with the same score must still get a deterministic order, or their ranks jump around between reads. The standard fix is to make the ordering key a composite of (score, tie-breaker) where the tie-breaker is the timestamp the score was reached (earlier reach ranks higher) or the player id. Deep dive 3 covers how to encode this.

API design

Five operations carry the system: submit a score, get a player's rank, get the top N, get "around me", and get the friends board.

Submit a score

POST /api/leaderboards/{board_id}/scores
Body: { "player_id": "p_123", "score": 48200 }
200 OK
Returns: { "score": 48200, "rank": 14238 }

Record a player's score for this board. For highest-score-wins semantics the server keeps the score only if it beats the stored one (a conditional / set-if-higher update); for latest-score semantics it overwrites, guarded by a version/timestamp so a stale event can't clobber a newer score (the write-path deep dive covers idempotency for each). It writes the authoritative PlayerScore, from which the sorted-set update is driven, then Returns the player's new score and (possibly slightly stale) rank so their own screen updates immediately.

Get a player's rank

GET /api/leaderboards/{board_id}/players/{player_id}/rank
200 OK
Returns: { "player_id": "p_123", "score": 48200, "rank": 14238 }

Return the player's global rank (1 = highest) and score. This is a rank-of-member lookup on the sorted set (Redis ZREVRANK, adding 1 since ranks are 0-based) — O(log N), never a row count.

Get the top N

GET /api/leaderboards/{board_id}/top?limit=100
200 OK
Returns: { "entries": [ { "rank": 1, "player_id": "p_9", "score": 992000 }, ... ] }

Return the top N players in order — the leaderboard's front page. It is a range-by-rank read of the first N members (Redis ZREVRANGE 0 N-1 WITHSCORES) and is served from a cache because it changes slowly.

Get "around me"

GET /api/leaderboards/{board_id}/players/{player_id}/around?k=5
200 OK
Returns: { "entries": [ { "rank": 14233, "player_id": "...", "score": 48260 }, ... ] }

Return the k players just above and below the caller. The server finds the player's rank, then reads the slice rank-k to rank+k (a range-by-rank read around that offset, Redis ZREVRANGE over that window). This is what shows a player exactly who to overtake next.

Get the friends board

POST /api/leaderboards/{board_id}/friends
Body: { "player_id": "p_123", "friend_ids": ["p_44", "p_88", ...] }
200 OK
Returns: { "entries": [ { "rank": 1, "player_id": "p_88", "score": 60100 }, ... ] }

Rank the caller among their friends only. This is a POST (not a GET) because the friend id list travels in the body and GET-with-body is poorly supported — alternatively pass ids as query params or derive them server-side from the social graph. The server fetches each friend's score (a batched score lookup, Redis ZMSCORE), then sorts that small set in the service — it does not maintain a separate sorted set per friend group (deep dive 3 explains why). A friend who has never scored comes back as nil from ZMSCORE and is treated as unranked (omitted or shown without a rank), not as score 0.

High-level architecture

Two paths matter: a write path (a score event durably stored and pushed into the sorted set) and a read path (rank / top-N / around-me served from the in-memory sorted set and caches). We'll walk each, then combine them.

1. Write path (submit a score)

A score event is written to the durable store first, and the sorted-set update is driven off that durable write — an outbox/CDC/stream consumer applies the ZADD, retrying until it succeeds. This is deliberately not a best-effort second write from the request handler: if the process crashed after the DB write but before a direct ZADD, the sorted set would be permanently stale until a full rebuild. Driving the update off the durable write guarantees the ZADD eventually happens.

flowchart LR
    Client["Game client"] -->|"POST score"| ScoreSvc["Score Service"]
    ScoreSvc -->|"set-if-higher (player_id, score)"| ScoreDB[("Score Store<br/>durable, authoritative")]
    ScoreDB -->|"did the best score change?"| ScoreSvc
    ScoreDB -.->|"change captured (outbox/CDC)"| Stream[["Event Stream"]]
    Stream -->|"apply (retried): ZADD GT member/score"| ZSet[("Leaderboard<br/>sorted set, in-memory")]
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class ZSet good
  1. The client submits a score to the Score Service.
  2. The service does a set-if-higher on the durable Score Store: for highest-score-wins, it keeps the new score only if it exceeds the stored one.
  3. The store reports whether the player's best score actually changed, so the caller's own screen can show the new score and rank immediately.
  4. The change is captured off the durable write (an outbox row, CDC, or a stream append committed with it) and a consumer applies it to the sorted set (Redis ZADD) — an O(log N) operation — retrying until it succeeds. The consumer converges the sorted set toward the durable store; a full rebuild (replay all PlayerScore rows) is the backstop, not the normal path.
  5. Idempotency is scoped to the semantics. For highest-score-wins, apply with a set-if-higher guard (Redis ZADD GT — the greater-than flag only raises a member's score), so the sorted set stays monotonic even if events arrive out of order or a retry re-applies an old event. For latest-score-wins (absolute replace), a plain overwrite is not idempotent under out-of-order or retried delivery — a stale event would clobber a newer score — so carry a monotonic version/timestamp and apply set-if-newer (only overwrite if the incoming version is newer than the applied one).

The rule that keeps the board correct: the durable score is written first and is authoritative; the sorted-set update is driven off that durable write and retried, so it always converges and can always be rebuilt.

2. Read path (rank / top-N / around-me)

Reads are the common case and are served from the in-memory sorted set, with the hottest pages cached.

flowchart LR
    Client["Client"] -->|"GET rank / top-N / around-me"| ReadSvc["Read Service"]
    ReadSvc -->|"top-N (cached page)"| Cache[("Top-N cache")]
    Cache -.->|"on miss / refresh"| ZSet[("Leaderboard<br/>sorted set (read replica)")]
    ReadSvc -->|"rank-of-member / range-around"| ZSet
    ReadSvc -->|"rank + entries"| Client
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class ZSet good
  1. The Read Service serves top-N from a small cache — the front page changes slowly, so it is refreshed every second or two rather than recomputed per request.
  2. Get-rank and around-me go to the sorted set (a read replica): rank-of-member is O(log N), and around-me is a range read of a small window around that rank, O(log N + k).
  3. It returns the rank and entries. Reads hit replicas so a burst of viewers never competes with the write path.

Tip

The top-N page and "my rank" are the two reads that fire on almost every leaderboard open. Cache the top-N (it barely moves), and answer rank from the sorted set's rank index — never by scanning or counting players.

3. Combined architecture

Putting them together: score events land in the durable store, and a consumer driven off that write applies the change to the in-memory sorted set (retried until it converges); reads serve rank, top-N, and around-me from the sorted set and its caches.

flowchart LR
    Client["Client"] --> GW["API Gateway"]
    GW -->|"POST score"| ScoreSvc["Score Service"]
    ScoreSvc -->|"set-if-higher"| ScoreDB[("Score Store<br/>durable, authoritative")]
    ScoreDB -.->|"change captured (outbox/CDC)"| Stream[["Event Stream"]]
    Stream -->|"apply (retried): ZADD GT / windowed boards / rebuild"| ZSetP[("Leaderboard sorted set<br/>primary, in-memory")]
    GW -->|"GET rank / around-me"| ReadSvc["Read Service"]
    GW -->|"GET top-N"| ReadSvc
    ReadSvc -->|"rank-of / range-around"| ZSetR[("Sorted set<br/>read replicas")]
    ReadSvc -->|"top-N page"| Cache[("Top-N cache")]
    ZSetP -.->|"replicate"| ZSetR
    GW -->|"GET friends"| FriendSvc["Friends Service"]
    FriendSvc -->|"batch score lookup + sort in service"| ZSetR
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class ZSetP,ZSetR good

The Score Store is the source of truth; the sorted set is a derived, in-memory index kept converged by the stream consumer and replicated to read replicas; the top-N cache is a derived page cache. Friends reads a small set of scores and sorts them in the service — no per-friend-group structure. Sorted-set updates land on the primary and replicate to read replicas, so a viewing storm never blocks score submissions.

Deep dives

1. The ranking data structure — why a sorted set

Everything rests on one query a naive design cannot do cheaply: given a score, what is its rank? — that is, how many players have a higher score. Storing scores is easy; answering "rank" fast is the whole problem. Here are three approaches, worst to best.

A plain table with ORDER BY score + COUNT(*) for rank (avoid — O(rank) per rank read)

Store one row per player (player_id, score) in a database. To get a player's rank, count how many players score higher: SELECT COUNT(*) FROM scores WHERE score > :my_score. To get the top N, SELECT ... ORDER BY score DESC LIMIT N.

Worked example — a player scores 48,200 on a 40M-player board:

get-rank for score = 48,200:
  SELECT COUNT(*) FROM scores WHERE score > 48200
  → the database scans / counts every row above 48,200
  → for a mid-table player that can be MILLIONS of rows counted
  → and this runs on EVERY rank read, ~100K times/sec

An index on score helps the top-N (ORDER BY ... LIMIT N can read the first N of the index). But rank still means "how many rows rank above me," which even with an index is O(rank) = O(R) — the B-tree has to walk and count the R higher-scoring rows because it stores no span counts. That is cheap for a top player (few rows above) but up to O(N) for a mid- or low-ranked player, and there is no O(1) "position of this key in the index" in a standard B-tree.

  • Pro: trivial to build; the data is durable and queryable with plain SQL; top-N is fine.
  • Con: get-rank is O(rank) — it walks and counts every higher-scoring row (cheap near the top, up to O(N) for a mid-ranked player). Around-me is worse (rank, then offset scan). At 40M players and 100K rank reads/sec, with most readers mid-board, it collapses.
  • Verdict: avoid as the live ranking structure. It is a fine durable store for the authoritative scores, but it cannot serve rank at read time.
Precompute ranks into a cached sorted list, refreshed by a batch job (works, with caveats)

Run a periodic job that sorts all players by score and writes out a ranked list (player → rank), cached for fast reads. Between runs, rank reads hit the cache and are cheap.

Worked example — a batch every 60 seconds:

batch job (every 60s):
  1. read all 40M scores
  2. sort by score desc
  3. assign rank 1..40M, write player_id -> rank into cache

reads (between runs): O(1) cache hit for a player's rank
problem: a score submitted at t=5s isn't reflected until the t=60s run
  → the player who just beat their record still sees the OLD rank
  → and recomputing a fresh rank means re-sorting 40M rows again

This makes reads cheap but pushes the cost into a heavy, repeated recomputation, and the ranks are stale between runs. Shortening the interval multiplies the sort cost; lengthening it makes ranks staler. Crucially, a player's own just-submitted score is not reflected until the next batch, which feels broken.

  • Pro: rank reads become O(1) cache hits; simple to reason about; the batch can run off to the side.
  • Con: ranks are stale up to one interval; the recompute is an O(N log N) full sort repeated forever; a player's own update does not appear immediately, which is the one thing that must feel instant.
  • Verdict: works only for coarse, non-real-time boards (a daily digest). For a live leaderboard the staleness of a player's own rank is unacceptable, and the repeated full sort is wasteful.
A sorted set (skip list + hash) — insert-with-score, rank-of, range-by-rank all O(log N) (recommended — the standard fix)

Use a sorted set: a structure that keeps players ordered by score and supports three operations directly — insert-or-update a player with a score, rank-of a player, and range-by-rank (give me players ranked X..Y). A single submission updates the structure in place; there is no re-sort and no per-read count. (In Redis this is a ZSET: ZADD to update, ZREVRANK for rank, ZREVRANGE for top-N and around-me, ZRANGEBYSCORE for score windows — but the operations are the point, not the commands.)

How it gets rank in O(log N), not O(N). A sorted set is built from two parts: a hash mapping player_id → score (so "what's this player's score" is O(1)), and a skip list ordering players by score. A skip list is a linked list with express lanes: higher layers skip over many nodes, so you can jump toward a target in O(log N) instead of walking every node. The key trick for rank: each forward pointer stores a span — how many nodes it jumps over. To compute a player's rank, you walk down from the top, and sum the spans of the pointers you follow. That running sum is the rank — reached in O(log N) hops, never by counting every player.

Worked example — the same 48,200 score, on the same 40M board:

flowchart LR
    Sub["submit score 48,200"] -->|"ZADD (update in place)"| ZS["sorted set<br/>hash: player→score<br/>skip list: ordered by score"]
    ZS -->|"ZREVRANK: sum spans down the skip list"| Rank["rank = 14,238<br/>O(log N) — a couple dozen steps, not 14,238 counts"]
    ZS -->|"ZREVRANGE 0..99"| Top["top-100 page"]
    ZS -->|"ZREVRANGE 14233..14243"| Around["around-me window"]
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class ZS good

Update is O(log N) (insert into the skip list). Rank-of is O(log N) (sum spans on the way down). Top-N and around-me are O(log N + k) (find the start rank, then read k contiguous entries). For 40M players, log₂(40M) ≈ 25, so every operation is O(log N) — a couple dozen steps, not tens of millions.

  • Pro: update, rank, top-N, and around-me are all O(log N) (or O(log N + k)); a submission updates in place with no re-sort; a player's own change is reflected immediately.
  • Con: it lives in memory (must be sized and made durable/rebuildable), and one giant board is a memory + hot-key concern at very large scale (deep dive 2).
  • Verdict: the standard structure for a leaderboard and the default at scale. Score, rank, and range all become logarithmic; the durable store keeps the authoritative scores behind it.

Important

The reason a sorted set beats a plain index is not "it's sorted" — a B-tree index is sorted too. It is that the skip list stores span counts, so the position (rank) of a key is computed by summing spans in O(log N), instead of counting every row that ranks above it.

2. Scaling reads and writes — sharding and the top-N fast path

One global board of 40M+ players in a single in-memory sorted set is both a memory problem (~6–8 GB and growing, and only one board — it does not comfortably fit one node) and a hot-key problem (all reads and writes hit one node). Three levers relieve it: cache the hot page, replicate for reads, and shard for size and write throughput.

Cache the top-N. The overwhelming majority of reads are the top-N page and each player's "my rank." The top-N barely moves — the same names sit at the top for minutes — so refresh it into a cache every second or two and serve nearly every top-N read from that cached page. This alone removes most read load from the sorted set.

Replicate for read scaling. Point rank and around-me reads at read replicas of the sorted set. Writes go to the primary and replicate outward; a crowd of viewers never competes with score submissions. Replicas are also why a few seconds of rank staleness is acceptable — a replica lagging slightly is fine for a rank.

Shard the board for size and writes. When one board no longer fits in memory or the write rate saturates one node, split the sorted set across shards. The critical question: does sharding keep get-global-rank cheap? It depends entirely on how you shard. Here are the two schemes, worst to best for global rank.

Shard by player-hash — spread players evenly, but global rank scatters (works, with caveats)

Hash the player_id and place each player on one of M shards. Players spread evenly, so memory and write load balance perfectly.

Worked example — 4 shards by hash of player id:

flowchart LR
    Q["get-global-rank(p_123)"] --> S0["shard 0<br/>count players above p's score"]
    Q --> S1["shard 1<br/>count above"]
    Q --> S2["shard 2<br/>count above"]
    Q --> S3["shard 3<br/>count above"]
    S0 --> Sum["global rank = 1 + sum of counts above across ALL shards<br/>a scatter-gather on every rank read"]
    S1 --> Sum
    S2 --> Sum
    S3 --> Sum
    classDef warn fill:#fef9c3,stroke:#ca8a04,color:#713f12;
    class Sum warn

Each shard is a valid sorted set locally, but a player's score is not comparable across shards. To get a global rank you must ask every shard "how many of your players score above 48,200?" and sum the answers. That is a scatter-gather on each rank read — and it grows with the number of shards.

  • Pro: perfectly even memory and write distribution; each shard is small; simple placement.
  • Con: global rank requires querying every shard and summing — a scatter-gather per rank read. Top-N also needs a merge across shards.
  • Verdict: works, and it is the natural choice for spreading write load, but it makes the headline query (global rank) expensive. Use it only if global rank can be approximated or is rare.
Shard by score-range (bucket) — global rank is a cheap sum of bucket sizes (recommended for cheap global rank)

Partition the score range into contiguous buckets and put each bucket on a shard: e.g. shard A holds scores 0–9,999, shard B holds 10,000–49,999, shard C holds 50,000+. A player lives on the shard for their score band. Keep a maintained count of how many players sit in each bucket.

The convention, stated explicitly and 1-based: global rank = 1 + (count of players above me in higher buckets) + (count of players above me within my own bucket).

Worked example — score 48,200 falls in shard B, near the top of that bucket (48,200 is close to B's 49,999 upper edge, so only a few hundred B players outscore it):

flowchart LR
    Q["get-global-rank(score 48,200)"] --> B["shard B (10k–49,999)<br/>players in B ABOVE 48,200 = 237<br/>(near top of bucket)"]
    B --> Sum["global rank = 1 + (higher buckets) + (above me in B)<br/>= 1 + 14,000 + 237 = 14,238"]
    C["shard C (50k+)<br/>maintained size = 14,000<br/>(all rank above)"] --> Sum
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class Sum good

Every C player (14,000 of them) outscores 48,200, and 237 B players sit above it — so 1 + 14,000 + 237 = 14,238 (equivalently, its within-bucket rank in B is 238, and 14,000 higher-bucket players sit ahead). Global rank is now cheap: the player's local rank within their own bucket, plus the total number of players in all higher-scoring buckets — and those bucket totals are small maintained counters, not a scan. You touch one shard for the local rank and add a few precomputed counts. Top-N is also cheap: it lives entirely in the highest bucket's shard — assuming that bucket holds at least N players; if the top bucket is sparse, top-N spills into the next-highest bucket, which you read too.

  • Pro: global rank stays cheap — local rank plus a sum of higher-bucket sizes (a handful of counters). Top-N is a single hot shard. Around-me is local unless the window straddles a boundary, in which case you read the adjacent shard and offset its ranks by the higher-bucket count.
  • Con: score buckets can be uneven (many players cluster in a mid band), creating a busy shard; buckets may need rebalancing as the score distribution shifts; a "just crossed a boundary" score is a cross-shard delete+insert that must also update the higher-bucket counters atomically, or global rank drifts (see below).
  • Verdict: the right sharding scheme when global rank must stay cheap. Range/bucket sharding keeps rank a sum of bucket sizes; pair it with rebalancing for skewed buckets.

Keeping bucket counters honest at the boundaries. The maintained higher-bucket counters are only trustworthy if they move together with the players. When a score crosses a bucket edge, that is a cross-shard delete-and-insert (remove from the old bucket, add to the new) plus an update to the affected higher-bucket counters — and those must be applied atomically, or the counts drift and every global rank derived from them is wrong. If instead you keep the counters asynchronously / eventually consistent (cheaper, no cross-shard transaction), then global rank derived from them is approximate near boundaries — fine for the deep tail, worth flagging near the top. Around-me across a boundary: when a player's r-k .. r+k window spills past a bucket edge, read the adjacent shard for the overflow and offset that second shard's local ranks by the querying bucket's higher-bucket count, so the merged window carries correct global ranks.

The write hot spot. Millions of score updates are spread across many different players, so unlike a single viral counter, the write load is naturally distributed by player — sharding (either scheme) spreads it further. The one place writes concentrate is the top of the board during a live event, but even there each update is an O(log N) in-place change, not a contended single counter.

Why eventual consistency makes all this safe. A player's own score update is applied to the durable store and their own view immediately, so their screen feels instant. The global rank they see everyone else at can lag a few seconds — served from a replica, refreshed from a cache — and nobody notices. That tolerance is exactly what lets you cache the top-N, read from replicas, and maintain bucket counts asynchronously.

Warning

Sharding by player-hash balances memory and writes but makes global rank a scatter-gather across every shard. If global rank is a core query, shard by score-range so rank stays a cheap sum of higher-bucket sizes — or accept an approximate rank for the deep tail (deep dive 3).

3. Rank queries — global rank, top-N, "around me", and friends

The sorted set makes four query shapes cheap. The trap in each is answering it by scanning or by building a structure you don't need.

Global rank is a rank-of-member lookup (Redis ZREVRANK, +1 for 1-based) — O(log N) by summing skip-list spans. Never a COUNT(*).

Top-N is a range-by-rank read of the first N members (Redis ZREVRANGE 0 N-1 WITHSCORES), served from the top-N cache since the front page changes slowly.

Around me is: find the player's rank r, then read the window r-k .. r+k (a range-by-rank read around that offset, Redis ZREVRANGE) — the window bounds shown as 1-based ranks for readability are passed to ZREVRANGE as 0-based indices (rank − 1). O(log N + k) for a small k. This is the "who do I overtake next" view.

Friends leaderboard. A player's friends are a small set — tens to low hundreds. Fetch each friend's score in one batched lookup (Redis ZMSCORE over the friend ids) and sort that small set inside the service. ZMSCORE returns nil for any friend who has never scored — treat those as unranked (drop them or list them without a rank), don't fold them in as score 0. Sorting 150 scores is trivial.

Maintain a dedicated sorted set per friend group (avoid — combinatorial write fan-out)

Keep, for every player, a private sorted set of just their friends, kept up to date as scores change.

Worked example — why the write cost explodes:

player p has 200 friends.
p also appears in the friend-set of ~200 OTHER players (friendship is mutual-ish).
when p's score changes ONCE:
  → update p's own friend board... and every friend board p appears in
  → ~200 sorted-set updates for one score change
across 40M players each with hundreds of friends:
  → one score update fans out to hundreds of writes
  → storage = one small sorted set PER player = tens of millions of structures

Every score change fans out to every friend group that contains the player. This is a huge write amplification and a storage explosion, all to save a sort of a few hundred numbers on read.

  • Pro: the friends read becomes a direct top-of-a-small-sorted-set read.
  • Con: one score change fans out to hundreds of friend-group updates; you store tens of millions of tiny sorted sets; friend list changes force rebuilds.
  • Verdict: avoid. The read it optimizes (sorting ~150 scores) was already trivial; the write fan-out and storage it creates are not.
Fetch friends' scores on read and sort the small set in the service (recommended)

On a friends-board request, batch-fetch the scores for the caller's friend ids from the main sorted set and sort those few hundred entries in the service.

Worked example — 150 friends:

flowchart LR
    Q["GET friends board (150 friend ids)"] -->|"ZMSCORE 150 ids (one batched read)"| ZS["main sorted set"]
    ZS -->|"150 scores"| Svc["service sorts 150 entries<br/>O(150 log 150) — negligible"]
    Svc --> Out["ranked friends list"]
    classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
    class Svc good

One batched score lookup plus an in-service sort of a small list — no extra structure to maintain, and it stays correct automatically because it reads the live scores.

  • Pro: no write fan-out, no per-group storage; always fresh; trivial CPU cost for a small friend set.
  • Con: each friends request does one batched multi-get and a small sort (fine); very large "friend" sets (thousands) would need pagination.
  • Verdict: the default. The friends board is a small-set read-time sort, not a maintained structure.

Time-windowed boards (daily / weekly / all-time). Keep a separate sorted set per windowboard:daily:2026-07-01, board:weekly:2026-W27, board:alltime. A score event updates the relevant current windows. Expire finished windows with a TTL (yesterday's daily board self-deletes) or rotate to a new key at the boundary. This keeps each window's structure small and lets old windows drop cheaply.

Tie-breaking (deterministic ranks). Equal scores must produce a stable order or ranks flicker between reads. Encode the ordering key as a composite: primary by score, then break ties by who reached it first (earlier timestamp ranks higher) or by player_id. A common trick is to fold both into one numeric ordering key — e.g. score in the high bits and an inverted timestamp in the low bits — so the sorted set's single numeric key already encodes the tie-break and rank is deterministic without a second sort. One caveat: Redis ZSET scores are IEEE-754 doubles with a 52-bit mantissa, so the composite is only exact if score-plus-tie-breaker fits in 52 bits; if it doesn't, use a lexicographic composite member and range by it (ZRANGEBYLEX) instead of packing everything into the numeric score.

Approximate rank for the deep tail. Exact rank only matters near the top, where players compete closely. For a player ranked 4,234,912, an exact integer rank is pointless — an approximate percentile ("top 12%") is what they actually want and is far cheaper. You can serve the deep tail from bucket counts (deep dive 2) or a probabilistic sketch, reserving exact O(log N) rank for the top of the board. Note the bucket-count shortcut is only available under score-range sharding, which maintains those counts as a byproduct; under player-hash sharding there are no global bucket counts, so you'd maintain a separate histogram/sketch of the score distribution to answer the percentile. Either way this turns the most expensive-to-shard query into an approximation exactly where precision has no value.

Tip

Match the query's precision to the player's need. Near the top, serve exact rank from the sorted set. In the deep tail, serve an approximate percentile from bucket counts — it is cheaper and is genuinely all the player wants to know.

Caution

Do not build a per-friend-group sorted set for every player, and do not serve global rank with a COUNT(*) over scores. Sort the small friend set on read, and answer rank from the sorted set's rank index (or bucket sums when sharded).

Tradeoffs & bottlenecks

  • In-memory sorted set vs durable store. The sorted set gives O(log N) rank/range but lives in memory; the authoritative scores must sit in a durable store so the board is rebuildable. This costs a rebuild path (replay scores into the sorted set) but buys speed and recoverability.
  • Score-range vs player-hash sharding. Range/bucket sharding keeps global rank cheap (local rank + higher-bucket counts) but risks uneven, skewed buckets; player-hash sharding balances memory and writes evenly but scatters global rank across all shards. Pick by whether global rank must stay cheap.
  • Exact vs approximate rank. Exact rank near the top is worth the cost; an approximate percentile for the deep tail is far cheaper and equally useful — matching precision to need is a real lever, not a shortcut.
  • Fresh vs cached top-N. Caching the top-N removes most read load at the cost of a second or two of staleness on the front page — acceptable, since the top barely moves.
  • Friends: read-time sort vs maintained structure. Sorting a small friend set on read is trivial and always fresh; a per-group sorted set optimizes a read that was never expensive while creating huge write fan-out — not worth it.
  • Real-time own-score vs eventually-consistent global rank. The player's own update is instant; everyone else's rank they see can lag a few seconds. That asymmetry is what makes caching, replicas, and async bucket counts safe.

Extensions if asked

If the interviewer wants to push beyond the core system, add only the extension that changes the design discussion:

  • Segmented leaderboards (region, level, game mode). Each segment is its own sorted set; a player may appear in several. Same structure, more boards.
  • Score decay / seasons. Scores decay over time or reset each season — implemented as time-windowed boards with rotation, plus a decay job that rewrites scores.
  • Percentile and distribution queries. "You're in the top 5%" needs maintained bucket counts (the score-range shards already provide them) rather than exact ranks.
  • Anti-cheat / score validation. Rejecting impossible scores before they reach the board is a separate validation concern that sits in front of the Score Service. There's no dedicated guide yet.
  • Real-time push. Pushing rank changes to a live-watching client (a tournament overlay) adds a streaming/websocket layer fed by the score event stream.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Picking a sorted set and explaining why — that the skip list's span counts give rank in O(log N), so update, rank, top-N, and around-me are all logarithmic, unlike a table's O(rank) rank count (up to O(N) for a mid-ranked player).
  • Keeping the sorted set as a rebuildable cache with the authoritative scores in a durable store, driving the sorted-set update off that durable write, and scoping idempotency correctly — set-if-higher (ZADD GT) for highest-wins, set-if-newer (version/timestamp guard) for latest-wins.
  • Serving the read-heavy path from caches and replicas — caching the slow-moving top-N and reading rank/around-me from replicas.
  • Reasoning about sharding — that score-range/bucket sharding keeps global rank cheap (local rank + higher-bucket counts) while player-hash sharding scatters it.
  • Serving friends by sorting a small set on read, not maintaining a sorted set per friend group.
  • Matching precision to need — exact rank near the top, approximate percentile for the deep tail — and accepting eventual consistency for global rank while the player's own score feels instant.

Before you finish, do a quick mistake check:

  • Did you use a sorted set for rank, instead of ORDER BY score + COUNT(*) per read?
  • Did you explain how the skip list gets rank in O(log N) (span counts), not the B-tree's O(rank) walk-and-count?
  • Did you keep authoritative scores in a durable store and treat the sorted set as a rebuildable cache?
  • Did you drive the sorted-set update off the durable write (retried), and scope idempotency right — set-if-higher (ZADD GT) for highest-wins, set-if-newer (version guard) for latest-wins?
  • Did you cache the top-N and read from replicas for the read-heavy path?
  • Did you pick a sharding scheme deliberately, and know that score-range keeps global rank cheap while player-hash scatters it?
  • Did you serve friends by sorting a small set on read, not a per-group sorted set?
  • Did you make ties deterministic (timestamp or player id) and consider approximate rank for the deep tail?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →