- The trap is reaching for
ORDER BY score LIMIT 100and never asking whether it survives a million reads a second. It does not. - Spotting that early is half the interview.
- The real fight is one scorching key, not where the data lives.
If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Redis sorted sets, not SQL. They give rank, range, and top-N in O(log N), and the active set is only 4 GB, so it fits in memory. A database doing live ORDER BY at a million reads a second is the thing you are avoiding.
- One packed sort key. Fold score and an inverted timestamp into a single 64-bit number so Redis handles the tie-break natively. Watch the 53-bit double-precision ceiling and test for it.
- Own rank is synchronous, everyone else lags. Update Redis and read the rank back in the same call, then persist durably through a queue, async. That split is exactly what users notice.
- The hot key is the enemy. One board key under a finale spike is the real limit. Beat it cheap-first: 1-second top-N cache, then replicas, then shard only when forced.
- Approximate the deep tail. Exact rank at position 4 million buys nothing a human cares about, so estimate it from a bucket histogram and save the exact merge for the top.
1Clarifying the requirements#
Never start drawing boxes. Start asking questions, because "leaderboard" means ten different things to ten different people, and the answers change which data structure I pick.
Candidate: Is this one global leaderboard, or many? Like per-game, per-region, per-friend-group?
Interviewer: Start with one global board. But assume we will want weekly and all-time versions too.
Candidate: When a player's score changes, do they need to see their new rank instantly, or is a few seconds of lag fine?
Interviewer: Their own rank should feel instant. Other people's ranks can lag a little.
Candidate: Do we need the exact rank, or is "top 2%" good enough for the big numbers?
Interviewer: Good question. Top of the board must be exact. For someone ranked 4 millionth, an approximate rank is fine.
Candidate: Scores only go up, or can they drop? And can two people tie?
Interviewer: Scores can go up or down. Ties happen a lot, break them by who hit the score first.
What those answers just handed me:
- Tie-break by earliest time means I cannot sort by score alone, so it changes how I encode the sort key.
- Own rank instant, others can lag is a gift: most reads go to a lagging replica or cache, only the writer's own rank needs the freshest path.
- Exact at the top, approximate at the tail means I can serve rank 4 million from a cheap estimate, not an expensive exact query.
Functional requirements
- Submit or update a player's score.
- Fetch the top N players (say top 100).
- Fetch a given player's exact rank and score.
- Fetch a window around a player (the 5 above and 5 below them).
- Support multiple boards: all-time, weekly, daily.
- Deterministic tie-break by earliest timestamp.
Non-functional requirements
- A player's own rank update feels instant (sub-200ms end to end).
- Reads dominate writes, roughly 20 to 1.
- Top-N reads must be exact and fast.
- Highly available. A brief stale read beats an error.
- Survive a traffic spike at tournament end without falling over.
Out of scope, on purpose: anti-cheat, score validation, the game logic itself. I note exactly where cheating would plug in, but I am not designing it today, and I say that out loud so the cut reads as deliberate, not forgotten.
This opening is scored before you draw a box. I am listening for whether you catch the two gifts in my answers: the tie-break changes your key, and "own rank instant, others lag" splits your read paths. Miss those and you will over-build the whole thing to be strongly consistent, which this problem never needed.
2Back-of-the-envelope estimation#
Get a feel for the size before picking tools. I would rather be roughly right and find the one number that drives the design than be precise and lost.
You are hunting one load-bearing number: the entire ranking set is tiny. That is what justifies an in-memory sorted structure instead of a database grinding through ORDER BY on every request. Find it fast, then move on.
Writes.
- Say 50 million daily active users, each finishing ~10 matches a session, each match end writing a score: 500 million score writes a day.
- Averaged: 500,000,000 / 86,400 is about 5,800 writes/sec. That is nothing. A single Postgres box takes that.
- Spikes are what kill you. A weekly tournament ends and everyone submits in a 10-minute window.
- If 20% of a day's writes land in those 600 seconds: 100M / 600 is about 166,000 writes/sec at peak, a 28x burst, all aimed at one board.
Reads.
- People stare at leaderboards far more than they play. Call it 20 reads per write.
- Average: about 116,000 reads/sec.
- Peak: north of 1,000,000 reads/sec during a finale, nearly all pointed at the top of a single board.
Storage for the ranking structure (the number that decides everything).
- Per player I need an ID and a score: a 16-byte player ID plus an 8-byte encoded score, roughly 24 bytes, call it ~80 bytes per entry with Redis overhead.
- 50M players x 80 bytes is about 4 GB per board.
- All-time, weekly, daily: three live boards, so about 12 GB of hot ranking data.
Bandwidth for a top-100 read.
- 100 rows x about 50 bytes is 5 KB. At 1M reads/sec that is 5 GB/sec if everyone pulled the full top-100.
- They will not all do that, but the math is plain: cache the top-100 hard and serialize it once, not recompute and re-serialize it a million times a second.
3Core APIs#
Keep these small and boring. The one opinion worth defending is idempotency.
POST /v1/scores
Content-Type: application/json
{
"board": "weekly",
"player_id": "p_8c3f12",
"match_id": "m_55120a",
"score": 42800,
"achieved_at": 1762147200123
}
GET /v1/leaderboards/weekly/top?limit=100
GET /v1/leaderboards/weekly/players/p_8c3f12/rank
GET /v1/leaderboards/weekly/players/p_8c3f12/window?above=5&below=5
A top-100 response looks like this:
{
"board": "weekly",
"as_of": 1762147260000,
"entries": [
{ "rank": 1, "player_id": "p_19a2", "score": 99120, "achieved_at": 1762140011000 },
{ "rank": 2, "player_id": "p_8c3f12", "score": 42800, "achieved_at": 1762147200123 }
]
}
Two choices worth defending:
- Idempotent on
(player_id, match_id). Mobile clients retry on flaky networks. Thematch_idis the idempotency key, so a retried write never double-counts a score or bumps a player twice. as_ofin every read. It tells the client how fresh the data is, so a caller can show "updated 2s ago" instead of pretending every read is live.
4High-level design#
The shape I would draw: a stateless API tier in front, Redis sorted sets as the ranking engine, a durable store behind it as the source of truth.
Why Redis, not SQL.
- Redis holds one sorted set per board, keeping members ordered by score.
ZADDinserts or updates,ZREVRANKgives a player's rank,ZREVRANGEgives the top N, all O(log N).- That is exactly the shape of this problem, and it is why the engine is a sorted set, not a SQL
ORDER BY.
Write path.
- A score comes in, the write service validates and enqueues it.
- An aggregator consumes the queue, updates Redis, and writes the durable row to Postgres.
- The queue flattens that 166,000 writes/sec burst into a steady drain the database can absorb, so a tournament spike becomes a buffer problem, not a thundering herd on the database.
Read path.
- Individual rank and window reads hit Redis directly, since
ZRANKandZREVRANGEare cheap. - Top-N reads hit a short-TTL cache in front of Redis, because the top of the board is the single most-requested thing and barely changes second to second.
Why keep Postgres if Redis has the data.
- Redis is my index, not my ledger.
- If a Redis node dies and the replica is cold, I rebuild the sorted set by replaying scores from Postgres.
- The durable store is also where analytics, support tools, and anti-cheat read history.
- Losing the index is a 30-second rebuild. Losing the ledger is losing the game.
5Data model#
Use two tables in Postgres for truth, and one key per board in Redis for serving.
Score events (the truth, append-friendly, one row per accepted score):
| Field | Type | Notes |
|---|---|---|
| id | bigint | Primary key, auto-increment |
| player_id | varchar(32) | Indexed |
| board | varchar(24) | all_time, weekly_2026w44, daily_2026_11_03 |
| match_id | varchar(40) | Unique with player_id for idempotency |
| score | bigint | The raw score value |
| achieved_at | bigint | Epoch millis, used for tie-break |
| created_at | timestamptz | When we recorded it |
- The unique constraint on
(player_id, match_id)is what makes a retried write a no-op, closing the idempotency loop from the API section.
Current best (makes rebuilds and "what is this player's live score" fast without scanning the event log):
| Field | Type | Notes |
|---|---|---|
| player_id | varchar(32) | Part of PK with board |
| board | varchar(24) | Part of PK |
| best_score | bigint | Highest accepted score on this board |
| achieved_at | bigint | Timestamp of that best score |
| updated_at | timestamptz | Last change |
In Redis.
- Each board is one key, for example
lb:weekly_2026w44. - The member is the
player_id. - The sorted-set score is a single encoded 64-bit number that folds in the tie-break, explained next.
6Deep dives#
6.1 Encoding the tie-break into one sort key#
Redis sorts by one floating-point score per member, but my requirement breaks ties by who reached the score first. I cannot store two sort keys in one sorted set, so I pack both into one number.
- Build a 64-bit integer: the top bits hold the game score, the low bits hold an inverted timestamp.
- Score dominates the ordering.
- When scores tie, the earlier
achieved_atmust rank higher, so invert the timestamp (subtract from a large constant) so earlier means a bigger packed value, which Redis sorts higher.
packed = (score << 23) | (MAX_TS - seconds_since_epoch_offset)
- The timestamp gets 23 bits, enough to resolve ties to the second across a tournament window. Score gets the rest.
- A single
ZADDwithpackedas the sort score gives correct ordering including the tie rule, for free, inside Redis. - On read I unpack the real score, so the
99120the player sees is the clean game score, not the packed integer.
Redis sorted-set scores are IEEE 754 doubles, which hold 53 bits of integer precision exactly. Push past 53 bits and you silently lose precision, and ranks go wrong in ways that are miserable to debug. Budget carefully: 30 bits of score (up to ~1 billion) plus 23 bits of time fits in 53. If the game needs bigger scores, switch to a Redis module with larger integer scores, or split into score-bucket keys so each holds a narrower range.
Reveal the answer
6.2 Making a player's own rank feel instant#
The requirement was: my own rank updates instantly, everyone else can lag. That asymmetry is a gift and I lean on it hard.
How it stays inside the budget.
- Update Redis synchronously on the write path and read the rank back in the same call.
- The player sees their true new rank in one round trip, well inside the sub-200ms budget.
- The durable write to Postgres goes async through the queue, so a slow disk never slows the response.
- If the durable write fails, a reconciler replays from the queue's dead-letter channel.
The trade I am making, out loud.
- Redis is now ahead of Postgres for a few hundred milliseconds. That is fine: Redis is my serving truth for rank, Postgres is my recovery truth.
- The only risk is a Redis crash in that tiny window losing an unpersisted write, which I bound two ways: AOF persistence on with per-second flush, plus the queue holding a second copy of every write until the durable insert confirms.
- If this were real money instead of game points, I would flip it and make the durable write synchronous before answering. For a leaderboard, speed wins.
6.3 The hot key and the top-N read storm#
This is the signature hard part, and it is where the interview is won.
- Every board is a single Redis key. At a finale, 1,000,000 reads a second can all target the top of one key.
- A single Redis shard tops out around 100K to 200K ops/sec for this kind of command.
- So the hot key, not storage, is the real bottleneck. Saying that sentence reframes the whole problem from "where does the data live" to "how do I serve one scorching key."
Three things fix it, stacked, cheapest first.
First, cache the top-100 aggressively.
- The top of the board is read far more than it changes meaningfully.
- Keep a serialized top-100 blob in a read cache with a 1-second TTL.
- At 1M reads/sec with a 1-second TTL, Redis itself serves roughly 1 top-N recompute per second instead of a million.
- Because the blob is pre-serialized JSON, I also skip the per-request serialization cost. This single step kills most of the problem.
Second, add read replicas.
- Rank and window reads for individual players cannot be cached as hard, since every player wants their own slice.
- Point those at Redis replicas and fan reads across them.
- Replicas lag by milliseconds, which satisfies "other people can lag."
- A writer still gets their own-rank read from the primary right after submitting, so they never see their fresh score missing.
Third, shard only when forced.
- For the rare board so large or hot that one node cannot hold or serve it, split into N sorted-set shards by hashing the player ID.
- Writes go to one shard cheaply. The pain is global rank.
- To answer "what is my overall rank" I ask each shard "how many players beat my packed score" and sum, which is N parallel
ZCOUNT-style queries merged. - Top-N across shards means pulling the top K from each and merging K*N candidates down to K. It works, it just costs more per read, so I would not do this on day one.
The deep tail gets an estimate, not a merge.
- For "rank 4 millionth, approximate is fine," skip the exact merge entirely.
- Keep a cheap histogram of score buckets per board and estimate rank from where the player's score falls: count everyone in higher buckets, add a fraction of the player's own bucket.
- That turns an expensive exact query into a lookup, and the interviewer already said approximate is acceptable down there.
- Exact rank at position 4 million buys nothing a human cares about.
7Bottlenecks and trade-offs#
The single hot key breaks first.
- Storage is small, average writes are trivial, but one board key under a finale spike is a real limit.
- The defense is layered: top-N cache first, replicas second, sharding only when forced.
- Sharding trades cheap reads for expensive global-rank merges, and most products never need it.
- I say the order out loud so the interviewer sees I reach for the cheap fix before the architectural one.
Redis as serving truth means a crash can lose the last second of writes.
- I accept that with eyes open.
- AOF persistence plus the durable queue means I can rebuild and reconcile.
- For a game leaderboard, losing a sub-second window and recovering it async is an acceptable price for sub-200ms rank reads.
- If this were money, I would flip the design and make the durable write synchronous.
The double-precision ceiling is a production landmine.
- It is easy to encode a tie-break, ship it, and discover six months later that high scores collide because the packed value blew past 53 bits.
- Write a test that asserts packed values stay under 53 bits for the game's max possible score, and fail the build if not.
- Cheap insurance against a brutal bug.
Time-windowed boards need lifecycle care.
- A weekly board is just a new Redis key each week, like
lb:weekly_2026w45replacinglb:weekly_2026w44. - The trap is expiring the old one.
- Set a TTL on daily and weekly keys so they self-clean, and snapshot final standings to Postgres before expiry so "last week's winners" survives.
- All-time has no TTL.
Cross-region (sketch it honestly if asked).
- A single Redis region adds latency for far-away players.
- Run a write-primary region and regional read replicas.
- Accept that the board is eventually consistent across regions by a second or two.
- Pin each player's own-rank read to the primary briefly after they submit, so they never see their score "disappear" when a lagging replica answers.
Where anti-cheat plugs in (out of scope, but name the seam).
- Structurally it sits in the write service before the
ZADD. - Validate the score against plausible bounds and server-side match state.
- Quarantine suspicious submissions into a review queue instead of the live board.
- Doing it there means a cheated score never pollutes the index in the first place.
Reach for the cheap fix before the architectural one, and say the order out loud.
8Summary#
The key decisions I would defend in the room:
- Redis sorted sets are the ranking engine. They give rank, range, and top-N in O(log N), and the whole active set is 4 GB, so it fits in memory on one node.
- Postgres is the durable source of truth and the rebuild path, not the serving path. Do not make a database do live
ORDER BYat a million reads a second. - Pack score and inverted timestamp into one 64-bit sort key so Redis handles tie-breaks natively. Watch the 53-bit double-precision ceiling, and test for it.
- Own rank is synchronous, everyone else lags. Update Redis and return the player's own rank in the same call, persist durably through a queue, async.
- The hot key is the real enemy. Beat it with a 1-second top-N cache, then replicas, then sharding only when a single node cannot cope.
- Approximate rank via a bucket histogram for the deep tail, since exact rank there buys nothing.
Start simple: one board, one Redis node, a cache in front, Postgres behind. Add replicas and sharding only when the numbers force you to. That progression, cheap fix before architecture, is the thing interviewers actually want to see.
"Your Redis node dies mid-tournament. What have you lost and how do you recover?"
"A million reads a second hit the top of one board. How does that not melt Redis?"
"Two players tie on score. How do you break it, and why can't you just store two keys?"
"A mobile client retries a score submission three times on a flaky network. Does the player jump three ranks?"
"How do you answer 'what is my rank' for the player ranked 4 millionth without a huge query?"
What you can now explain in the room
- Why a leaderboard is a sorted-set problem, and why 4 GB of hot data means it lives in memory, not a SQL ORDER BY.
- How to fold score and an earliest-time tie-break into one packed key, and the 53-bit ceiling that will bite you.
- Why your own rank can be synchronous and instant while everyone else's safely lags behind a queue.
- Why the single hot key, not storage, is the real bottleneck, and the cache-then-replicas-then-shard order to beat it.
- How global rank gets expensive the moment you shard, and why you delay sharding until forced.
- Why approximate rank in the deep tail is a feature, and where anti-cheat and lifecycle TTLs plug in.
If you want to see how different companies push on this one, check our company-specific guides for gaming and social-feed teams, where the window-around-me and cross-region questions come up the most.
Get a prep plan built around your Design a Leaderboard interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.