← System design

Social & Feed

Design Top-K Leaderboard

Showing the top ten is easy. Telling player number 31,442,907 that they just climbed to rank 31,440,118, a few milliseconds after their match ends, is the actual problem. A leaderboard is a ranking query that has to stay exact while scores change tens of thousands of times a second, and it has to be rebuilt from scratch without anyone noticing if the in-memory copy is lost.
Core: rank-aware sorted sets fed from a durable score log80M players on the all-time board45K score updates a second at peakYour rank in under 20 ms

01

What we are building

01

What we are building

Take a competitive mobile game with 80 million players who have ever finished a ranked match. After every match the player sees their new score and global rank, and can open boards for the whole world, their country, this week and their friends. A season lasts eight weeks, after which seasonal boards reset and the old standings are archived for rewards.

Behind the screen are three different kinds of question. Top N: who are the best hundred players? Rank of one player: where exactly do I stand among 80 million? Neighbourhood: who is just above and just below me? The first is the same for everyone and caches well. The second and third are personal, and a database index does them badly.

Why rank is the hard query

A sorted index can return the top hundred rows cheaply. But "what is my rank" means "how many players have a higher score than me", which a plain B-tree answers by counting, touching up to tens of millions of entries for a mid-table player. The design hinges on a structure that keeps counts as it goes, so rank is a logarithmic lookup rather than a scan.

02

Scope and targets

02

Scope and targets

Functional
  • Record a player's score after each ranked match, as reported by our game servers.
  • Show the top 100 of any board, paginated further on request.
  • Show a player's exact rank and score, and the ten players around them.
  • Boards per scope: all-time, current season, this week, today, and per country.
  • A friends board ranking a player against the people they follow, up to 1,000 friends.
  • Seasonal reset at a fixed time, with the final standings frozen for rewards.
  • Moderators can remove a cheating player from every board at once.
Non-Functional
  • A new score is reflected in ranks within two seconds.
  • Own-rank and around-me queries return in under 20 ms p99.
  • Ranks are exact for the top million; beyond that, exact is preferred but a short lag is fine.
  • No score that a game server reported is ever lost, even if the ranking layer is.
  • The same match result processed twice never counts twice.
  • Ties are resolved the same way for every reader.
Settle the scoring rule first

Is the board ranked by best single score, or by a running total such as rating points? Best-score boards only ever move a player up, and duplicate updates are harmless. Cumulative boards move both ways and need strict idempotency. This walkthrough uses a rating that changes after every match, which is the harder case.

03

Sizing it

03

Sizing it

Assume 30 million daily players who each finish about 25 ranked matches a day, and who look at a leaderboard about 40 times a day. Peaks come at the end of weekly events, about five times the average.

QuantityWorkingResult
Score updates per day30M x 25 matches750M
Average update rate750M / 86,400about 8,700 / s
Peak update rate5x average at an event deadlineabout 45K / s
Board writes per updateall-time, season, week, day, country5, so 225K sorted set writes / s at peak
Leaderboard reads per day30M x 401.2B
Peak read rate1.2B / 86,400 x 5about 70K / s
Memory per sorted set entryskip list node, hash entry, 12 byte member idabout 100 B
All-time board80M x 100 Babout 8 GB
Season and weekly boards55M and 40M active playersabout 5.5 GB and 4 GB
Daily and country boards30M daily, plus 80M spread across countriesabout 3 GB and 8 GB
Total ranking memorysum, plus replicasabout 29 GB, 58 GB with one replica
This fits in memory comfortably

Under 30 GB of ranking data is small for modern servers. The biggest single board is 8 GB, which one Redis primary handles without trouble. Memory is not the constraint.

Write concentration is

All 45,000 updates a second at peak touch the same all-time sorted set, and each costs a logarithmic insert. A single Redis thread can do on the order of 100,000 such updates a second, so one board on one shard has about 2x headroom. That is enough for now and tells you exactly when sharding becomes necessary.

04

Endpoints

04

Endpoints

POST/internal/v1/match-resultsGame server reports a finished match
// Called only by game servers, signed with a service credential, never by the client { "match_id": "m_20261001_9f3a77", // idempotency key "ended_at": "2026-10-01T18:42:09Z", "results": [ { "player_id": "p_5512", "rating_delta": 18, "country": "BR" }, { "player_id": "p_8090", "rating_delta": -14, "country": "IN" } ] } // 202 Accepted once written to the score log; ranks update asynchronously
GET/v1/boards/{board}/top?offset=0&limit=100Top of a board
// board examples: global-all, season-14, week-2026-40, country-BR-season-14 { "as_of": "2026-10-01T18:42:10Z", "entries": [ { "rank": 1, "player_id": "p_0042", "name": "nightowl", "rating": 3920 } ] } // Cached for one second at the edge: identical for every caller
GET/v1/boards/{board}/players/{player_id}?around=5A player's rank and neighbours
{ "rank": 31440118, "rating": 1210, "percentile": 60.7, "around": [ /* five above, the player, five below */ ] }
GET/v1/boards/{board}/friends/{player_id}Friends board
{ "entries": [ { "rank": 1, "player_id": "p_7771", "rating": 1650 } ], "friend_count": 212 }
DELETE/internal/v1/players/{player_id}/rankingsRemove a banned player from every board
// Marks the player excluded in the score store, then removes them from each sorted set
Clients never send scores

The public API is read only. Scores come from authoritative game servers that ran the match. A client that could post its own score would turn the leaderboard into a contest of who can edit HTTP requests best.

05

System overview

05

System overview

Write path through the log, read path from sorted setsDiagram
flowchart LR
	GS["Game servers"] --> SS["Score service"]
	SS --> AC["Anti-cheat checks"]
	SS --> DB[("Score store, source of truth")]
	SS --> K[["Score log"]]
	K --> RW["Ranking workers"]
	RW --> RZ[("Redis sorted sets, one per board")]
	RZ --> RP["Redis replicas"]
	U["Player app"] --> CDN["Edge cache, top N"]
	CDN --> QA["Query API"]
	QA --> RP
	QA --> RZ
	QA --> FR[("Friends graph")]
	K --> AR["Season archiver"]

Writes and reads take separate paths, a CQRS shape. On the write path, game servers report each match to the score service. It rejects results that fail anti-cheat checks, applies each player's rating change to the score store in one transaction keyed by match id, so a repeated report changes nothing, and appends the new absolute ratings to a partitioned score log. The score store is the durable record; everything downstream can be rebuilt from it.

Ranking workers consume the log and write each player's new rating into every board that player belongs to. Because the log carries absolute values, not deltas, applying the same event twice gives the same result, and a worker that crashes and replays a few seconds of events does no harm.

On the read path, the top of each board is served from an edge cache refreshed every second. Rank and neighbourhood queries go to the query API, which reads from Redis replicas for most requests and from the primary when a player has just finished a match and must see their own update. Friends boards fetch the friend list and look up scores in bulk.

Score service

The only writer to the score store. Owns idempotency and validation. A unique constraint on match id is the simplest correct duplicate check.

Score log

Partitioned by player id so updates for one player stay ordered. Retained for a week, which is long enough to rebuild any board from a recent snapshot plus replay.

Ranking workers

Batch updates with pipelining, a few hundred per round trip. They map each event to its boards: global, season, week, day and country.

Redis sorted sets

One key per board, for example lb:season-14. Each board lives on one shard with a replica. Time-scoped boards carry an expiry a few days after they close.

Query API

Turns board names into keys, resolves player names and avatars from a profile cache, and serves friends boards.

Season archiver

At reset time, freezes final standings into the score store and object storage, so rewards are computed from a stable copy rather than a live set.

06

Deep dive: exact ranks for tens of millions

06

Deep dive: exact ranks for tens of millions

What the structure must do

Insert or update a score in logarithmic time, return the k-th entry in logarithmic time, and return the position of a given player in logarithmic time. A balanced tree or a skip list can do the first two. The third needs each node to know how many entries lie beneath or beyond it.

Skip lists that count

A skip list is a sorted linked list with express lanes. Every element sits on the bottom level; about half also appear on the level above, a quarter on the level above that, and so on. A search starts on the top lane, moves right while the next element is still smaller than the target, then drops a level. It reaches any element in about log n steps, and inserts only need to fix the pointers along that path.

Redis sorted sets add one field to every forward pointer: its span, meaning how many bottom-level elements that pointer jumps over. While searching for a member, add up the spans of every pointer you follow. When you arrive, the total is the member's position. Inserts and deletes adjust spans on the same path they already touch, so the cost stays logarithmic. That is how ZREVRANK on 80 million entries returns in microseconds.

A sorted set pairs that skip list with a hash table from member to score. Looking up a player's current score is a hash lookup; updating it removes the old node and inserts a new one. Range commands such as ZREVRANGE walk forward from a position found by rank, which is exactly what "five above and five below me" needs.

Answering "where am I" for one playerDiagram
flowchart TD
	Q["Request: season board, player p_5512"] --> H["Hash lookup: current rating 1210"]
	H --> R["ZREVRANK: sum spans on the way down"]
	R --> N["ZREVRANGE rank minus 5 to rank plus 5"]
	N --> P["Fetch names and avatars from profile cache"]
	P --> OUT["Rank, percentile and neighbours"]

Breaking ties fairly

Ratings cluster heavily. Tens of thousands of players can share the same rating, and Redis then orders them by member id, which is arbitrary and feels unfair. The usual product rule is that whoever reached the score first ranks higher. You can encode that inside the sorted set score itself: packed = rating x 2^32 + (2^32 - 1 - seconds_since_season_start). Higher rating always wins; for equal ratings, the earlier time leaves a larger remainder and ranks higher.

Mind the arithmetic. Sorted set scores are 64-bit floats with 53 bits of exact integer precision. Spending 32 bits on time leaves 21 bits for the rating, so ratings must stay below about two million. That is plenty for a rating system, but a game with huge point totals should use coarser time, such as minutes, or keep ties in a secondary structure. Silent rounding here produces wrong ranks that nobody notices until a prize dispute.

When one sorted set stops being enough

One shard is fine at 45,000 updates a second. Suppose the game grows fivefold, or adds a global event where everyone plays at once. You have three options.

Split by score range. Shard one holds ratings above 3,000, shard two holds 2,000 to 3,000, and so on. Rank is the count of players in all higher shards plus your rank inside your own shard. Top N reads one shard. The weakness is balance: ratings are not uniform, the boundaries need to move as the distribution shifts, and a player crossing a boundary needs a delete on one shard and an insert on another.

Split by player hash. Writes spread evenly across, say, eight shards. Top N becomes a merge of each shard's top N, which is cheap. Exact rank needs ZCOUNT of scores above yours on every shard and a sum, which is eight small calls in parallel, and each still logarithmic.

Tiered. Keep an exact sorted set only for the top tier, for example the best 200,000 players, which is where rank actually matters to people. Everyone else gets a percentile from a histogram of rating buckets maintained by the ranking workers: "top 38%" is honest and cheap, and nobody below the top tier cares whether they are 31,440,118th or 31,440,190th.

The path we would take

Start with one sorted set per board, one shard each, plus replicas for reads. When a board approaches the single-thread write limit, move it to hash sharding with a parallel count for exact rank. If the product accepts it, show percentiles below a cut-off to keep the expensive exact work small.

07

Choices and their costs

07

Choices and their costs

Chosen
In-memory sorted sets for ranking

Logarithmic insert, rank and range queries with microsecond latency. You must treat the data as a derived copy that can be lost, and pay for enough RAM to hold every board.

Built for this query
Rejected
Rank with a SQL index

Top N with an index is fast, but rank means counting rows with a higher score. For a mid-table player that is tens of millions of index entries per query.

Rank is a scan
Chosen
Durable store first, ranking via a log

A score is safe the moment it is committed, ranking can lag or crash without losing anything, and replays rebuild boards. Ranks trail scores by a second or so.

Safe and rebuildable
Rejected
Write to the database and Redis in one request

Simple and immediately consistent when both succeed. When one fails, the two drift silently, and a slow Redis now slows match completion.

Drift on partial failure
Chosen
A separate sorted set per scope

Every board query is a direct lookup. Each update writes to five sets instead of one, and memory scales with the number of scopes.

Fast reads per scope
Alternative
One board, filtered at query time

Less memory and one write per update, but a country rank means filtering the global set, which loses the logarithmic rank lookup entirely.

Breaks rank queries
Chosen
Friends boards computed on request

Fetch up to 1,000 friend ids, read their ratings in one batched call, sort in the API. A few milliseconds, no extra storage and always current.

Cheap at this size
Rejected
Precomputed sorted set per player

Instant reads, but one score change must update the board of every friend, which is a fan-out of hundreds of writes per match.

Write amplification
Chosen
Rename and expire on season reset

At the deadline, start writing to lb:season-15 and let the old key expire after archiving. The switch is a config change and no large delete blocks Redis.

Instant reset
Rejected
Delete the board in place

Removing a 5 GB key synchronously can stall a Redis thread for seconds. Use asynchronous deletion if you must, but versioned keys avoid the problem.

Blocking delete

08

When things break

08

When things break

💥
The Redis primary for the season board dies

A replica is promoted within seconds, but asynchronous replication means it may lack the last few hundred updates. Some players see stale ranks.

Ranking workers track the log offset they have applied. After a failover, rewind a minute and replay. Because events carry absolute ratings, replaying is safe and the board converges.
🧮
Boards drift away from the score store

A bug in a worker skips events for players in one country. Nothing errors; their ranks are just wrong.

Run a reconciliation job that samples players, compares the store with every board, and repairs differences. Alert on the mismatch rate rather than waiting for support tickets.
⏰
Everyone checks the board at the event deadline

Reads jump to several times peak in the last minute of a weekly event, and writes spike too as final matches end.

Serve the top of every board from the edge cache, send rank queries to replicas, and keep primaries for writes and read-your-own-update requests. Freeze the final standings from the score store, not from a busy live set.
🔁
A game server reports the same match twice

A timeout makes the server retry. If the rating delta were applied twice, both players' ratings would be wrong forever.

Deduplicate by match id with a unique constraint in the score store, inside the same transaction that applies the deltas. Downstream, only absolute values travel, so duplicates there are harmless.
🕵️
A cheating ring floods the top 100

Modified clients win matches impossibly fast and climb to the top of the global board within an hour.

Scores only come from game servers. Add plausibility rules on rating change per hour, hold suspicious jumps for review before they reach top-tier boards, and give moderators one action that excludes a player everywhere.
🌋
The whole ranking cluster is lost

A bad configuration push wipes every Redis node. All boards are empty.

Rebuild from the score store: stream all players sorted by board and pipeline inserts. At around 500,000 inserts a second, the 80 million entry board is back in under three minutes. Serve the cached top of each board meanwhile and show "ranks updating".

09

Interview notes

09

Interview notes

1

Name the three queries. Top N, my rank, around me. Then point out that only the first is easy with a normal index.

2

Explain how rank becomes logarithmic. A skip list with spans, or an order-statistic tree. Saying "Redis has a command for it" without knowing why is a weak answer.

3

Do the memory sum. Tens of millions of entries at about a hundred bytes is gigabytes, not terabytes. It justifies keeping everything in memory.

4

Keep a source of truth outside Redis. Sorted sets are a derived view. Show how you rebuild them and how long it takes.

5

Have a tie-breaking rule. Packing time into the score is neat; knowing the 53-bit precision limit is what impresses.

6

Know when to shard and how. State the single-thread write ceiling, then compare score-range, hash and tiered approaches.

7

Mention idempotency. Retries are normal. Deduplicate at the source of truth and send absolute values downstream.

8

Raise cheating. Server-authoritative scores and a single exclusion action show you have shipped a competitive product before.

10

Growing the design in stages

11

Growing the design in stages

Stage 1: soft launch
One database, top N only

A scores table with an index, a top 100 query cached for a minute, and no personal rank. Fine for tens of thousands of players.

Stage 2: personal ranks
Add Redis sorted sets

Write to the database, then update one sorted set per board. Ranks and neighbourhoods become instant. A nightly job rebuilds boards to fix any drift.

Stage 3: millions of players
Score log, workers and replicas

Decouple ranking from match completion through a log, apply absolute values idempotently, add read replicas and an edge cache for the top of each board.

Stage 4: tens of millions
Scoped boards, fair ties and reconciliation

Seasonal, weekly and country boards with versioned keys, packed scores for tie-breaking, and a reconciliation job comparing boards with the store.

Stage 5: global events
Sharded boards and percentiles

Hash-shard the busiest boards with parallel counts, show percentiles below the top tier, and run regional ranking clusters for regional boards.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.