Design a Real-Time Leaderboard
A sorted set makes rank queries O(log n). The interesting parts are ties, time windows, and boards too big for one node.
- 12K events/speak score events
- ~17 GBall boards together: one primary plus a replica
- O(log n)add points, top 100 and rank
Show what to say
“Each board is a Redis sorted set, so adding points, the top 100 and any player's rank all cost O(log n). Validated results go through Kafka into one Lua script per event that skips replayed offsets, so nothing counts twice. Redis is a rebuildable index, not the source of truth: the database holds the durable totals.”
Trade-offs
- Sorted set vs SQL index: O(log n) rank over millions, but memory-bound with a derived copy to sync.
- Kafka first vs writing Redis directly: durable before the 202 and replayable, but about 100 ms lag and more moving parts.
- Shard by player hash vs score range: hash gives even writes but fans out reads; range reads top K from one shard but runs hot.
Bring it up before they ask
Kafka redelivers and ZINCRBY isn't idempotent, so points would count twice; the script stores each partition's last applied offset and skips anything at or below it, atomically.
New to the words? Read the one-minute primer
Each board is a : adding points, the top 100 and any player's rank all cost O(log n). Scores flow through , and a replayed event is skipped, so Redis is a rebuildable index, not the source of truth.
Functional
- Submit a match result; points add to the player's totals
- Top K (e.g. 100) with ranks
- My rank, and the 5 players above and below me
- Daily, weekly and all-time boards
- New scores visible within about a second
Non-functional
- Reads under 50 ms at p99
- About 12K score events per second at peak
- No score lost or counted twice
- Server-side validation (anti-cheat)
Out of scope
- Matchmaking and the game itself
- Prize payouts (we freeze final standings)
- Friends and country boards (follow-ups)
It all hinges on one question: what is my rank? Answer it fast, then cover ties, time windows and .
Clarifying questions and scale
Read more · 220 words
- How many players and matches a day?
- Total points, or best single score?
- Ties: shared rank, or first to get there?
- Which windows (daily, weekly)? How fresh?
- Who computes the score: the game server, or the client?
| Quantity | Estimate |
|---|---|
| Players | 50M registered, 10M daily active |
| Score events | 10M × 20 matches = 200M a day ≈ 2.3K per second |
| Peak | About 5× average ≈ 12K events per second |
| Redis work per event | One Lua script: an offset check, 3 ZINCRBY, 2 EXPIRE |
| Rank reads | ≈ 12K per second at peak, O(log n) each |
| Top-100 reads | Cached 1 s: a few Redis calls per second |
| Memory per member | ~100 bytes |
| All-time board | 50M × 100 B ≈ 5 GB |
| Daily boards | 10M × 100 B ≈ 1 GB each; 8 days kept ≈ 8 GB |
| Weekly boards | ~20M weekly actives ≈ 2 GB each; 2 kept ≈ 4 GB |
About 17 GB and 12K scripts a second: one plus a . Don't yet.
Why a sorted set
An index on points serves the top 100 fine.
Read more · 348 words
But "my rank" has to count everyone above you: millions of entries, 12K times a second. ranks fails too, since one score change shifts everyone it passes.
Do the sum first. The count walks every higher-scoring row, so the cost grows with players times reads:
| Players | Rank reads | Verdict |
|---|---|---|
| 10K | 1 a second | SQL is fine |
| 1M | 100 a second | Borderline; cache or add replicas |
| 50M | 12K a second | Sorted set; SQL would scan millions each time |
Try first for a small game. When a tournament final ends and a million players open "my rank" together, only the keeps up.
A sorted set keeps a count on every link of a . Adding the counts on the way down gives your rank in a few dozen hops, even for 50M players.
| Command | What it does | Cost |
|---|---|---|
ZINCRBY lb 50 maya | Add points (creates the member if new) | O(log n) |
ZADD lb GT CH 950 maya | Raise a best score, never lower it | O(log n) |
ZREVRANGE lb 0 99 WITHSCORES | Top 100, highest first | O(log n + 100) |
ZREVRANK lb maya | 0-based position, highest first | O(log n) |
ZCOUNT lb (1250 +inf | Players with strictly more points | O(log n) |
ZSCORE lb maya | One player's points | O(1) |
ZUNIONSTORE w 7 d1 … d7 | Sum several boards into one | O(N) + O(M log M) |
SELECT COUNT(*) + 1 FROM player_totals
WHERE game_id = 1 AND period = 'all' AND points > 900;API and data model
Read more · 200 words
| Method | Path | Result |
|---|---|---|
| POST | /matches/:id/result | From the match server, signed: { playerId, points, playedAt } → 202 |
| GET | /boards/:board/top?n=100 | Top N with ranks; cached for 1 s |
| GET | /boards/:board/players/:id | { score, rank }, or 404 with no score this period |
| GET | /boards/:board/players/:id/around?r=5 | The 2r + 1 players around this one |
| GET | /boards/:board/stream | SSE: top-10 changes, at most once per second |
:board is all, daily or weekly. Only match servers may submit, never the player's app.
The database is the durable copy. holds an index of it.
| Redis key | Holds | Expiry |
|---|---|---|
lb:{g1}:all | Sorted set: player id → season points | None; a new key per season |
lb:{g1}:daily:2026-10-13 | Sorted set: points that UTC day | 8 days |
lb:{g1}:weekly:2026-W42 | Sorted set: points that ISO week | 14 days |
lb:{g1}:best | Sorted set: best single match, composite score | None |
off:{g1}:<partition> | String: last Kafka offset applied | None |
The {g1} tag keeps a game's keys together, so one Lua script can update them all.
CREATE TABLE score_events (
match_id uuid PRIMARY KEY, -- a redelivered event inserts nothing
game_id int NOT NULL,
player_id bigint NOT NULL,
points int NOT NULL CHECK (points BETWEEN 0 AND 10000),
played_at timestamptz NOT NULL, -- decides the daily and weekly period
status text NOT NULL DEFAULT 'ok' -- 'voided' after an anti-cheat review
);
CREATE TABLE player_totals ( -- what a Redis rebuild loads
game_id int NOT NULL,
period text NOT NULL, -- 'all', 'd:2026-10-13', 'w:2026-W42'
player_id bigint NOT NULL,
points bigint NOT NULL,
reached_at timestamptz NOT NULL, -- when the total last changed: the tie-break
PRIMARY KEY (game_id, period, player_id)
);
CREATE TABLE consumer_offsets ( -- written in the same transaction as the totals
partition int PRIMARY KEY,
next_offset bigint NOT NULL
);Architecture
Play Score update, then Top 100 from , My rank and around me, and fails.
Tap any box to see what it is and what it talks to.
- Service
- Queue
- Worker
- Database
- Client
- Cache
Text description
- Maya's match ends. The match server, not her phone, posts the signed result: match m-881, +50 points, played at 20:14:07 UTC.
- Anti-cheat checks the signature and whether the result is plausible: points per minute, match length, matches played this hour.
- Valid.
- The Score API appends the event to score-events, keyed by player id, with acks=all. Once Kafka acknowledges, the score is durable.
- 202 Accepted. Nothing on this path touches Redis, so a burst of match ends just queues up in Kafka.
- Two consumer groups read the event independently, typically within 100 ms.
- One Lua script: skip the event if its partition offset was already applied; otherwise ZINCRBY the all-time, today's and this week's board by 50.
- Maya's all-time total is now 1,250. All three boards and the offset changed in one atomic step.
- The DB writer inserts the event (a redelivery hits the match_id primary key and does nothing) and bumps player_totals in the same transaction, a few hundred events per batch.
- The replica follows within milliseconds. Rank reads go there, so the primary only takes writes. Maya's new rank is visible about 100 ms after her match ended.
Submitting a score and ties
Play it, then try a tie, an event replayed after a , and a result anti-cheat rejects.
Read more · 60 words
Now race it.
Thirty players, one of them you, so ties are common. Press Run, then switch Ties to number equal scores three ways. Press +100 to me to climb. Set to 3 to see per-shard results merged.
- Service
- Queue
- Worker
- Cache
Text description
- Match server → Score API: POST /matches/m-881/result
- Score API → Anti-cheat: validate signature, points
- Anti-cheat replies to Score API: ok
- Score API → score-events: append (acks=all)
- Score API replies to Match server: 202 Accepted
- score-events sends (async) to Board updater: event, offset 7,420
- Board updater → Redis: Lua: offset check, ZINCRBY ×3
- Redis replies to Board updater: all-time total 1,250
- Board updater → Redis: ZADD best GT CH enc(950, t)
- Redis replies to Board updater: 1: best raised to 950
- Board updater sends (async) to Redis: PUBLISH rank change
Step-by-step answer
Five moves: clarify (scale, windows, ties), estimate (writes and memory: one node?), pick the structure (a per board and period), handle the edges (ties, windows, replays, cheating), then scale and recover ( later).
Read more · 621 words
Name the queries
Top K, my rank, and the players around me. Top K is shared, so it.
One sorted set per board
ZINCRBYadds points,ZREVRANGE 0 99is the top 100,1 + ZCOUNT key (myScore +infis my rank. All O(log n).Pick a tie rule and say it
Shared ranks ("1224") are plain
ZCOUNT. Dense ranks ("1223") need a second set. First to reach wins ("1234") puts the time inside the score:export const TIME_BITS = 28; // 2^28 s ≈ 8.5 years of season export const MAX_POINTS = 2 ** (53 - TIME_BITS) - 1; // 33,554,431 // earlier time → larger remainder → higher among equal points const encode = (points, tsSec) => points * 2 ** TIME_BITS + (2 ** TIME_BITS - 1 - tsSec);Scores are doubles, exact only to 2^53, so both parts must fit in 53 bits. Adding to a composite runs in the Lua script.
Time windows are separate keys
Keys come from when the match was played, so a 23:59:59 result arriving after midnight counts for its day. One script bumps the daily, weekly and all-time keys.
Write path: validate, log, apply
The Score API checks the signed result, appends it to and returns
202. Kafka can redeliver andZINCRBYisn't , so the script stores the last applied offset and skips anything at or below it.Read path: cache what everyone shares
The top 100 is cached for one second (caching). "My rank" is two or three O(log n) calls on a , well inside 50 ms.
Durability: Redis is an index
A DB writer saves each event, the totals and the Kafka offsets in one . If the dies, the replica takes over and Kafka replays. If both die, reload the totals into a fresh instance, then replay.
Past one node: shard by player
Split a board by player hash (sharding strategies). Writes touch one shard. Reads :
- Top K: each shard returns its full top K and the API merges them.
- My rank: 1 plus the sum of each shard's
ZCOUNT.
Line the Kafka partitions up with the shards.
Or shard by score range
Top K comes from one shard, but the top shard runs hot and players move. Hash is safer.
Approximate the tail, push the head
Nobody cares about #18,402,118 versus #18,402,090. Keep exact ranks for the top 10K and show a percentile ("top 37%") below. Push top-10 changes over (polling, SSE and WebSockets).
Implementation
Read more · 74 words
applyScorechecks and stores the offset in the same script as the board changes.myRankusesZCOUNT;ZREVRANKalone breaks ties by member name.encoderefuses more than 33,554,431 points.mergeTopKneeds each 's full top K, not K / N.periods.jsgets ISO weeks right at the year boundary.
import Redis from 'ioredis';
import { boardKeys, dailyKeysOfWeek, DAY_TTL_S, WEEK_TTL_S } from './periods.js';
import { encode, seasonSeconds } from './tie-score.js';
import { competitionRanks } from './ranking.js';
import { shardOf, mergeTopK, rankFromShardCounts } from './sharded-rank.js';
export const redis = new Redis(process.env.REDIS_URL);
// The board updater calls this once per Kafka event. Kafka delivers at least once, and ZINCRBY
// is not idempotent, so the script first checks the last offset this partition applied. The
// offset and the three boards change together, so after a failover they still agree.
redis.defineCommand('applyScore', {
numberOfKeys: 4, // offset key, all-time, daily, weekly (one hash tag, so one slot)
lua: `
local applied = tonumber(redis.call('GET', KEYS[1]) or '-1')
if tonumber(ARGV[1]) <= applied then return false end
redis.call('SET', KEYS[1], ARGV[1])
local total = redis.call('ZINCRBY', KEYS[2], ARGV[2], ARGV[3])
redis.call('ZINCRBY', KEYS[3], ARGV[2], ARGV[3])
redis.call('EXPIRE', KEYS[3], ARGV[4], 'NX')
redis.call('ZINCRBY', KEYS[4], ARGV[2], ARGV[3])
redis.call('EXPIRE', KEYS[4], ARGV[5], 'NX')
return total`,
});
export async function applyScore(game, partition, offset, { playerId, points, playedAt }) {
const k = boardKeys(game, playedAt);
const total = await redis.applyScore(`off:{${game}}:${partition}`, k.all, k.daily, k.weekly, offset, points, playerId, DAY_TTL_S, WEEK_TTL_S);
return total === null ? null : Number(total); // null: a redelivery, already counted
}
// On startup and after a failover, seek each partition to what Redis actually holds.
export const appliedOffset = async (game, partition) => Number((await redis.get(`off:{${game}}:${partition}`)) ?? -1);
// Best single-match score; ties go to whoever reached it first. GT only ever raises a score, and
// the same points reached later encode smaller, so they never replace the earlier entry.
// Replaying the same ZADD changes nothing: idempotent for free.
export async function submitBest(game, playerId, points, nowMs, seasonStartMs) {
const composite = encode(points, seasonSeconds(nowMs, seasonStartMs));
return (await redis.zadd(`lb:{${game}}:best`, 'GT', 'CH', composite, playerId)) === 1; // true: new best
}
// Top N with "1224" ranks. O(log n + N). (ZRANGE key 0 N-1 REV is the Redis 6.2+ spelling.)
export async function top(key, n = 100) {
const flat = await redis.zrevrange(key, 0, n - 1, 'WITHSCORES');
const rows = [];
for (let i = 0; i < flat.length; i += 2) {
const score = Number(flat[i + 1]);
const prev = rows.at(-1);
rows.push({ id: flat[i], score, rank: prev && prev.score === score ? prev.rank : rows.length + 1 });
}
return rows;
}
// My rank: 1 + players with strictly more points. ZREVRANK alone would break ties by member name.
export async function myRank(key, playerId) {
const score = await redis.zscore(key, playerId); // O(1)
if (score === null) return null; // no score in this period yet
const above = await redis.zcount(key, `(${score}`, '+inf'); // O(log n): nothing is scanned
return { score: Number(score), rank: above + 1 };
}
// The 2r + 1 players around me; the window slides instead of shrinking at either end.
export async function aroundMe(key, playerId, r = 5) {
const [[, pos], [, size]] = await redis.pipeline().zrevrank(key, playerId).zcard(key).exec();
if (pos === null) return [];
const start = Math.max(0, Math.min(pos - r, size - (2 * r + 1)));
const flat = await redis.zrevrange(key, start, start + 2 * r, 'WITHSCORES');
const rows = [];
for (let i = 0; i < flat.length; i += 2) rows.push({ id: flat[i], score: Number(flat[i + 1]), position: start + i / 2 + 1 });
return rows;
}
// Rebuild a week from its seven daily boards (after losing it, or after voiding a cheater's
// events). O(N) + O(M log M) over millions of members blocks Redis for seconds: run it off-peak
// or on a spare instance, never per request. Needs all seven dailies, hence the 8-day TTL.
export async function rebuildWeek(game, anyDayMs) {
const days = dailyKeysOfWeek(game, anyDayMs);
const { weekly } = boardKeys(game, anyDayMs);
await redis.zunionstore(weekly, days.length, ...days, 'AGGREGATE', 'SUM');
await redis.expire(weekly, WEEK_TTL_S);
}
// Past one node: N keys per board by player hash, each with its own hash tag so Redis Cluster
// can place them on different nodes. A write touches one shard; rank and top-K ask all of them.
const shardKey = (game, s) => `lb:{${game}:${s}}:all`;
const shards = n => Array.from({ length: n }, (_, s) => s);
export async function shardedRank(game, playerId, n) {
const score = await redis.zscore(shardKey(game, shardOf(playerId, n)), playerId);
if (score === null) return null;
const counts = await Promise.all(shards(n).map(s => redis.zcount(shardKey(game, s), `(${score}`, '+inf')));
return { score: Number(score), rank: rankFromShardCounts(counts) };
}
export async function shardedTop(game, n, k = 100) {
const lists = await Promise.all(shards(n).map(s => top(shardKey(game, s), k))); // k from each, not k / n
return competitionRanks(mergeTopK(lists.map(l => l.map(({ id, score }) => ({ id, score, at: 0 }))), k));
}Failure modes and trade-offs
Read more · 253 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Redis primary crashes | A few ms of scripts lost | Promote the replica; replay Kafka |
| Kafka redelivers | Points counted twice | Offset check in the script |
| Both Redis copies lost | No leaderboard | Rebuild from the database; serve the cached top 100 |
| Result arrives after midnight | Wrong day | Period from played_at |
| Evening peak | Updater lag | Kafka absorbs it; alert on lag |
| Cheater found later | Fake scores | Void events, recompute totals |
ZUNIONSTORE per request | Redis blocks | Rebuild jobs only |
Trade-offs
| Choice | Pros | Cons |
|---|---|---|
| Sorted set vs SQL index | O(log n) rank | Memory-bound; a copy to sync |
| Kafka first vs Redis directly | Durable, replayable | ~100 ms lag, more parts |
Write every period vs ZUNIONSTORE | Every board live | Three writes per event |
| Shard by player hash | Even writes | Reads fan out |
| Shard by score range | Top K from one shard | Hot top shard |
| Percentile for the tail | Tiny memory | No exact rank down there |
| Composite tie score | Fixed order for ties | Fewer bits for points |
Follow-up questions
How would you build a friends leaderboard?
Fetch your friends' scores with one ZMSCORE, sort in the API and briefly.
How would you add country boards?
One more key per country, written by the same script.
How do you stop cheating?
Signed, server-computed scores, plausibility rules at ingest, anomaly detection, and prizes held until review.
What changes at a billion players?
About 100 GB for the all-time board. Hash-shard it, or keep exact ranks for the top million and percentiles below.
How do you show rank changes live?
with "top 10 changed" at most once a second. Notify "you were overtaken" only across lines that matter.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.
Patterns behind this
The coding patterns this design is built on. Practise them and the design questions get easier.
- Heaps: Top-K, K-way Merge and Two HeapsThe top k of a score stream is the heap pattern; a sorted set is the same idea stored, with rank queries on top.
- Design Data Structures: LRU, LFU and FriendsNo single plain structure gives both a fast score update and a fast rank. The page pairs them in a sorted set, the same pick-the-structures move.