Blueprint
Realtime & messaging foundation

Design a gaming leaderboard

A question with one right data structure — the scoring is on whether you can prove why SQL can't do it and what happens when one Redis node isn't enough.

asked at Riot Games · Zynga · Amazon Sometimes asked ~30 min walkthrough
01

Scope the requirements

This is a data-structure question wearing a system design costume. The interviewer wants the sorted set fast, then spends the rest of the time on durability, cheating, and what breaks past one node.

Functional — what it must do

  • Award points when a player wins a match — writes come from the game server, never the client.
  • Show the top 10 players, updated in real time.
  • Show any player's exact rank, plus the players around them (say ±4 positions).
  • Monthly reset — each month is a fresh tournament with its own board.
  • Explicitly cut: multiple game modes and friends-only boards — both come back cheaply as extra keys, and I'd say so.

Non-functional — the numbers that shape the design

  • Real time: a win is visible in the rankings immediately — no hourly batch recompute.
  • Rank and top-10 reads in single-digit milliseconds — this sits on a game results screen.
  • Scale: 5M DAU, 25M MAU — every monthly player holds a score.
  • Scores are durable: Redis losing power must not lose anyone's points.
Say this out loud

The word doing the work in the requirements is rank. Top 10 is easy in anything; the exact rank of player 12 million, in milliseconds, while scores change hundreds of times a second — that's what kills SQL and picks the data structure for me.

02

Back-of-envelope estimates

The arithmetic here delivers a punchline: the whole leaderboard fits in one Redis instance with room to spare, so the design challenge is correctness and durability, not throughput.

QuantityEstimateHow you got there
Score updates~600/s5M DAU × 10 matches/day ÷ 86,400 ≈ 580; peak 5× ≈ ~3,000/s.
Top-10 reads~60/sroughly once per user per day: 5M ÷ 86,400 ≈ 58, peak ~300. Reads are almost negligible.
Members per board25Mone entry per monthly active player.
Raw data size~650 MB25M × (~24 B id + score + overhead) — a few GB with skiplist overhead; one node fits it.
Redis headroom~100k ops/speak load is ~3k ops/s against ~100k a node handles — 30× headroom before sharding is even a conversation.
Say this out loud

Three thousand updates a second against a structure that does a hundred thousand, and a few gigabytes against a box with sixty-four — one Redis node carries this entire product. I'll design the sharded version too, but I want it on record that it isn't needed at 25 million users.

03

Sketch the API

Three endpoints. The security decision is the interesting one: the write path is internal-only, because a leaderboard that trusts clients is a cheating leaderboard.

POST/api/v1/scoresbody: { userId, points }. Called by the game server only — internal, authenticated service-to-service. Never exposed to clients.
GET/api/v1/scores→ top 10 as [{ userId, name, score, rank }] — hydrated with profile details, ready to render.
GET/api/v1/scores/{userId}→ the player's score and exact rank; ?surrounding=4 adds the four players either side.
  • The client never reports its own score. The game server validates the match outcome and calls the internal write endpoint; the public surface is read-only. Saying this unprompted is worth points — accepting client-submitted scores is the most common instant-fail in this question.
  • Rank responses carry profile data (name, avatar) so clients make one call — but that data is hydrated at read time from the profile store, never stored in the ranking structure.
04

Data model

One Redis sorted set per month is the entire serving model; a boring relational table behind it is the source of truth. Keeping those two roles separate is the design.

leaderboard:{YYYY_MM}
Redis sorted set: member = user_id, score = points
ZINCRBY to award, ZREVRANGE 0 9 for top 10, ZREVRANK for rank, ZREVRANGE rank-4 rank+4 for surroundings — every requirement is one native O(log n) command.
player_scores
user_id · month · score · updated_at (MySQL)
The durable record. Rebuilds Redis after a failure; feeds analytics and dispute resolution.
users
user_id PK · name · avatar
Hydrates the top-10 payload via MGET/batch read at request time — profile blobs never live in the sorted set.
The database call

Redis sorted sets, without hesitation — the structure is a hash plus a skip list, so score updates and rank lookups are O(log n) and range reads are O(log n + m), which is precisely this question's access pattern as native commands. MySQL sits behind it as the system of record, not a serving path. I'd switch — or rather, supplement — only past hundreds of millions of players, where I'd move to sharded sets with approximate global ranks, because exact rank at that scale stops being worth its cost.

05

High-level design

Two thin services over one sorted set: the game service owns the validated write path, the leaderboard service owns reads. Durability rides an async queue to MySQL so the hot path never waits on disk.

client service data async Game client Game service validates wins Leaderboard service read path Redis sorted set leaderboard:{month} MySQL scores + profiles Score events Kafka Persister writes system of record hydrate names ZINCRBY match result GET top 10 score event ZREVRANGE
the ranking read and write both terminate at redis — mysql is off the hot path entirely, reached synchronously only to hydrate display names.
  1. A match ends → the game client reports the result to the game service, which validates it server-side (the client is never trusted with points).
  2. The game service runs ZINCRBY leaderboard:2026_07 1 user123 — the ranking is updated in real time, in one O(log n) operation.
  3. It also drops a score event onto Kafka; the persister consumes it and updates the MySQL row — durability without adding disk latency to the game flow.
  4. A player opens the leaderboard → the leaderboard service runs ZREVRANGE 0 9 WITHSCORES, batch-fetches the ten profiles, and returns the enriched top 10 in a few milliseconds.
  5. For "where am I?", ZREVRANK gives the exact rank and one more ZREVRANGE around it gives the neighbours — the relative leaderboard costs two commands.
06

Deep dives — where the interview is won

Phase 01

Why SQL can't do this, said with numbers

Rank in a relational table is SELECT COUNT(*) FROM scores WHERE score > x — a scan over a 25M-row index for every rank request, repeated for every player who opens the screen, while the same table absorbs 3,000 updates a second that each invalidate every cached rank below them. Even a covering index makes this thousands of rows examined per query; it's real-time in the demo and dead at launch. Materialised rank columns fare worse: one score change shifts the rank of every player below, so writes become unbounded.

The sorted set solves it structurally: a hash gives O(1) member→score, a skip list keeps members score-ordered so rank is an O(log n) walk with node-span counting, not a count of everything above you. This is the expected opening dive — the interviewer wants the why, not just the word Redis. SQL isn't discarded; it's demoted to the system of record, which is the job it's actually good at.

query postgres leaderboard req SELECT ... ORDER BY 25M rows seconds :(
SQL rank query scans + orders every time. 25M rows makes this a full-table sort per request.
Trade-offI give up SQL's query flexibility on the serving path — no ad-hoc filters like "rank among players in Brazil" without building a separate keyed set for it. Each new slice is a new sorted set, paid in memory.
Phase 02

Redis is the ranking, not the record: durability by rebuild

The failure question is guaranteed: "Redis dies — do we lose everyone's points?" No, because Redis was never the system of record. Every score event lands in MySQL via the Kafka path, so recovery is a rebuild: replay the month's rows into a fresh sorted set with a pipeline of ZADDs — 25M entries load in a couple of minutes. Redis persistence (AOF every second, plus a replica for fast failover) narrows the window so a rebuild is rarely even needed; the MySQL rebuild is the backstop that makes the answer airtight.

The ordering subtlety worth volunteering: during a rebuild or failover there's a brief window where Redis lags MySQL. That's fine — the leaderboard is allowed seconds of staleness under failure; what it's not allowed is permanent loss. Separating those two guarantees explicitly is what makes this section land.

update sorted set read Score update Redis ZSET O(log N) Top-100 read ZADD ZRANGE
redis ZADD keeps sorted set updated in O(log N). ZRANGE returns page in O(log N + k).
Trade-offDual-writing to Redis and (via Kafka) to MySQL means the two can briefly disagree — I accept seconds of divergence, reconciled by rebuild, rather than paying for a distributed transaction on every match win.
Phase 03

Sharding, honestly: two bad options and when you need neither

Past one node there are two partition schemes, and both cost something real. Range partition by score band (shard 1 holds 0–100 points, shard 2 holds 101–500, …): top 10 reads only the highest shard — cheap — but a player crossing a band boundary must be moved between shards on update, and the bands need rebalancing as the score distribution shifts. Hash partition by user_id (Redis Cluster): updates are trivial and balanced, but top 10 becomes scatter-gather — fetch each shard's local top 10, merge — and exact global rank degrades to a sum of local ranks, an approximation, or a periodic recompute.

The honest close: at 25M members and ~650 MB, one node has 30× headroom on both memory and ops, so I'd run a primary with a replica and skip sharding entirely. Naming the two schemes, their costs, and then declining them on the numbers is a stronger answer than sharding by reflex — over-engineering the easy dimension is a scored mistake here.

option problem Shard by user Shard by score Can't merge rank Skewed hot shard
sharding by user_id breaks global rank. sharding by score range breaks on skew. keep one node for global.
Trade-offHash sharding keeps writes simple and reads approximate; score-band sharding keeps reads exact and writes migratory. Neither is free, which is exactly why not sharding — while you can defend it with arithmetic — is the best move.
Phase 04

Ties, resets, and the composite-score trick

Two players on 87 points — who's ranked higher? Redis breaks ties lexicographically by member, which is arbitrary and feels unfair. The standard fix is a composite score: pack the tiebreak into the number, for example score × 10^10 − last_update_timestamp, so equal points rank by who got there first. It works because ZSET scores are doubles with room to spare at these magnitudes; interviewers like this trick and it takes one sentence to deploy.

The monthly reset costs nothing by design: the month is in the key, so 1 August starts writing leaderboard:2026_08, which begins empty — no mass deletion, no migration. The old key hangs around for "last month's winners", then gets an expiry or an archive to MySQL. Rollover under live writes is a one-line policy: a win at 23:59:59 counts to the month the game service timestamps it into, and a second of fuzziness at the boundary is acceptable for a game.

input encoding zset raw score + ts composite float score.ts ZSET member
tie-break: score encoded as float64 with timestamp in low bits. one score, deterministic order.
Trade-offThe composite score means the stored number is no longer the display score — every read pays a small decode, and cross-system consumers must know the encoding. Cheap, but it must be documented or it becomes a bug.
07

Follow-ups you should expect

How would you build a friends-only leaderboard?
No new infrastructure: fetch the friend list (typically a few hundred ids), pipeline ZSCORE for each against the month's board, and sort in the service — a few hundred O(1) lookups is sub-millisecond work. Materialising a per-user friends ZSET only becomes worth it if friends-boards dominate traffic.
What changes at 500 million players?
Exact global rank stops being worth its cost. I'd hash-shard the sorted sets, precompute the global top-K every few seconds via scatter-gather, and serve individual ranks as approximations — percentile bands or a sum of local ranks refreshed periodically. "You're in the top 2%" is honest and cheap; an exact rank of 173,406,912 is expensive and meaningless.
Could you do this on DynamoDB without Redis?
Partially — a GSI on score with write sharding (salt the partition key across N shards, scatter-gather reads) handles top-10 respectably and is operationally lighter. But exact per-user rank has no native answer; you'd approximate or maintain count structures yourself. That's why the canonical design keeps Redis for ranking even in an otherwise serverless stack.
How do you stop cheaters?
Structurally, first: scores are written only by game servers that validated the match — the write endpoint isn't reachable from clients, so there's no score-submission API to abuse. Beyond that it's anomaly detection on the event stream — impossible win rates, impossible match frequency — flagged for review and rolled back in both stores, which the durable MySQL history makes possible.
The top-10 read rate spikes 1000× during a tournament final — problem?
Not really: 300 QPS × 1000 is 300k reads/s of the same ten members. Cache the rendered top-10 payload for one second — in the service or a local cache — and Redis sees one read a second regardless of viewers. A one-second-stale top 10 during a spike is invisible to users; this is the cheapest cache decision in the whole design.

Where candidates lose the room

  • Computing rank with SQL COUNT or ORDER BY per request and calling it real time — the scan-per-read cost is the exact thing this question tests.
  • Recomputing the top 10 by scanning all users on every read instead of maintaining an ordered structure that already knows.
  • Stuffing profile blobs into the sorted set — it bloats 650 MB into many GB and turns every profile edit into a leaderboard write.
  • Accepting client-submitted scores — a trust-boundary failure that no data structure can compensate for.
  • Treating Redis as the only copy: no durable store behind it means one power cut deletes the month's tournament.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.