BackendMedium35 min

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.

Say it in 30 seconds
  • 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

0 of 0 sections ticked
30-second answer

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?
QuantityEstimate
Players50M registered, 10M daily active
Score events10M × 20 matches = 200M a day ≈ 2.3K per second
PeakAbout 5× average ≈ 12K events per second
Redis work per eventOne Lua script: an offset check, 3 ZINCRBY, 2 EXPIRE
Rank reads≈ 12K per second at peak, O(log n) each
Top-100 readsCached 1 s: a few Redis calls per second
Memory per member~100 bytes
All-time board50M × 100 B ≈ 5 GB
Daily boards10M × 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:

PlayersRank readsVerdict
10K1 a secondSQL is fine
1M100 a secondBorderline; cache or add replicas
50M12K a secondSorted 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.

CommandWhat it doesCost
ZINCRBY lb 50 mayaAdd points (creates the member if new)O(log n)
ZADD lb GT CH 950 mayaRaise a best score, never lower itO(log n)
ZREVRANGE lb 0 99 WITHSCORESTop 100, highest firstO(log n + 100)
ZREVRANK lb maya0-based position, highest firstO(log n)
ZCOUNT lb (1250 +infPlayers with strictly more pointsO(log n)
ZSCORE lb mayaOne player's pointsO(1)
ZUNIONSTORE w 7 d1 … d7Sum several boards into oneO(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
MethodPathResult
POST/matches/:id/resultFrom the match server, signed: { playerId, points, playedAt } → 202
GET/boards/:board/top?n=100Top 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=5The 2r + 1 players around this one
GET/boards/:board/streamSSE: 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 keyHoldsExpiry
lb:{g1}:allSorted set: player id → season pointsNone; a new key per season
lb:{g1}:daily:2026-10-13Sorted set: points that UTC day8 days
lb:{g1}:weekly:2026-W42Sorted set: points that ISO week14 days
lb:{g1}:bestSorted set: best single match, composite scoreNone
off:{g1}:<partition>String: last Kafka offset appliedNone

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.

A queue is a line at a ticket window: people wait in order and are served one at a time.

Press Play to watch it run · 10 steps

Match results are validated, appended to Kafka and applied to Redis sorted sets by a board updater, while a DB writer keeps the durable copy. Players read the top 100 from a one-second cache and their own rank from a Redis replica.

Real-time leaderboard architectureMatch results are validated, appended to Kafka and applied to Redis sorted sets by a board updater, while a DB writer keeps the durable copy. Players read the top 100 from a one-second cache and their own rank from a Redis replica.signed resultvalidateappendconsumeevents + totalsconsumescriptafter failoverrebuildtop, my ranktop 100ZREVRANGE, ZCOUNTServer-authoritative: it computes the points. The player's phone never submits its own score.Match serversigns resultsScore API×NAnti-cheatrules + signatureThe commit point: once Kafka acknowledges (acks=all), the score is durable. Also the replay log for rebuilds.score-eventsKafka, by playerDB writerbatchedscore_events (one row per match) and player_totals per period: the source a rebuild loads from.Scores DBevents, totalsBoard updaterLua: ZINCRBY ×3Player appLeaderboard API×NTop-100 cache1 s, in-processlb:{g1}:all, one key per day and per week, and the last applied Kafka offset per partition.Redis primaryZSETs, ~17 GBRedis replicareads, failover

Tap any box to see what it is and what it talks to.

  • Service
  • Queue
  • Worker
  • Database
  • Client
  • Cache
Open in playground
Text description
  1. 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.
  2. Anti-cheat checks the signature and whether the result is plausible: points per minute, match length, matches played this hour.
  3. Valid.
  4. The Score API appends the event to score-events, keyed by player id, with acks=all. Once Kafka acknowledges, the score is durable.
  5. 202 Accepted. Nothing on this path touches Redis, so a burst of match ends just queues up in Kafka.
  6. Two consumer groups read the event independently, typically within 100 ms.
  7. 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.
  8. Maya's all-time total is now 1,250. All three boards and the offset changed in one atomic step.
  9. 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.
  10. 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.

Press Play to watch it run · 11 steps

The Score API validates and appends to Kafka; the board updater applies the event to Redis. Switch branches to see a new personal best, a score that isn't one, a tie with a rival, a replayed event, and a rejected result.

Submit a match resultThe Score API validates and appends to Kafka; the board updater applies the event to Redis. Switch branches to see a new personal best, a score that isn't one, a tie with a rival, a replayed event, and a rejected result.POST /matches/m-881/resultvalidate signature, pointsalt [valid]okappend (acks=all)202 Acceptedevent, offset 7,420Lua: offset check, ZINCRBY ×3alt [new personal best]all-time total 1,250ZADD best GT CH enc(950, t)1: best raised to 950PUBLISH rank change
  • Service
  • Queue
  • Worker
  • Cache
Text description
  1. Match server → Score API: POST /matches/m-881/result
  2. Score API → Anti-cheat: validate signature, points
  3. Anti-cheat replies to Score API: ok
  4. Score API → score-events: append (acks=all)
  5. Score API replies to Match server: 202 Accepted
  6. score-events sends (async) to Board updater: event, offset 7,420
  7. Board updater → Redis: Lua: offset check, ZINCRBY ×3
  8. Redis replies to Board updater: all-time total 1,250
  9. Board updater → Redis: ZADD best GT CH enc(950, t)
  10. Redis replies to Board updater: 1: best raised to 950
  11. Board updater sends (async) to Redis: PUBLISH rank change
Sharding splits one overflowing cabinet into three narrower ones, each holding only one slice of the alphabet.
Interactive

Leaderboard race

Match results stream into Redis sorted sets. Watch ranks move.

#Playerlb:allScore1Nia1,3502Milatie1,3002Omartie1,3002Emmatie1,3002Diegotie1,3006Yukitie1,200Around meZREVRANGE lb:all 19 2320Ravi75021Elitie70021Youtie70023Zoe60024Lenatie500

read: ZCOUNT lb:all (700 +inf → 20

Press Run (or Step) to stream match results, or +100 to me. On lb:all, you are #21 (20 players above you).

Speed and Redis commands
Redis commands (newest first, every event is also written to the other board)No events yet.read: ZCOUNT lb:all (700 +inf → 20

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
  1. Name the queries

    Top K, my rank, and the players around me. Top K is shared, so it.

  2. One sorted set per board

    ZINCRBY adds points, ZREVRANGE 0 99 is the top 100, 1 + ZCOUNT key (myScore +inf is my rank. All O(log n).

  3. 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.

  4. 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.

  5. Write path: validate, log, apply

    The Score API checks the signed result, appends it to and returns 202. Kafka can redeliver and ZINCRBY isn't , so the script stores the last applied offset and skips anything at or below it.

  6. 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.

  7. 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.

  8. 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.

  9. Or shard by score range

    Top K comes from one shard, but the top shard runs hot and players move. Hash is safer.

  10. 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
  • applyScore checks and stores the offset in the same script as the board changes.
  • myRank uses ZCOUNT; ZREVRANK alone breaks ties by member name.
  • encode refuses more than 33,554,431 points.
  • mergeTopK needs each 's full top K, not K / N.
  • periods.js gets 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 breaksEffectMitigation
Redis primary crashesA few ms of scripts lostPromote the replica; replay Kafka
Kafka redeliversPoints counted twiceOffset check in the script
Both Redis copies lostNo leaderboardRebuild from the database; serve the cached top 100
Result arrives after midnightWrong dayPeriod from played_at
Evening peakUpdater lagKafka absorbs it; alert on lag
Cheater found laterFake scoresVoid events, recompute totals
ZUNIONSTORE per requestRedis blocksRebuild jobs only

Trade-offs

ChoiceProsCons
Sorted set vs SQL indexO(log n) rankMemory-bound; a copy to sync
Kafka first vs Redis directlyDurable, replayable~100 ms lag, more parts
Write every period vs ZUNIONSTOREEvery board liveThree writes per event
Shard by player hashEven writesReads fan out
Shard by score rangeTop K from one shardHot top shard
Percentile for the tailTiny memoryNo exact rank down there
Composite tie scoreFixed order for tiesFewer 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 3

Score 2 out of 3 or better to mark this page complete.

1. Maya has 1,300 points; Leo and Ravi both have 1,200. With shared ranks (1224), how do you get Ravi's rank from Redis?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.