Design a Real-Time Leaderboard
Design a leaderboard for a game with millions of players: instant score updates, exact rank lookups, top-K queries, and neighborhood views, sharded past a single Redis node and hardened against cheaters.
1Requirements
Functional
- • Record a score event for a player and update their leaderboard position immediately
- • Return the top 10 players globally and per region
- • Return any player's exact rank and score
- • Return the neighborhood view: 5 players above and below a given player
- • Support monthly leaderboards that reset, with past boards viewable read-only
Non-functional
- • Rank reads visible within 1 second of a score update (real time for humans)
- • Read-heavy: ~10:1 reads to writes; top-10 is the hottest query
- • p99 latency under 50ms for rank and top-K queries
- • Scores must never be lost or double-counted; the leaderboard is the game's economy
- • Handle a 10x traffic spike during tournaments without degrading reads
2Back-of-envelope estimation
| Monthly active players | 25M | 5M daily actives |
| Score updates | 5M DAU x 10 games/day = 50M/day | ~580 writes/sec average, ~5,800/sec peak |
| Rank reads | ~500M/day | 10:1 read ratio, ~58k/sec peak with top-10 dominating |
| Sorted set memory | 25M members x ~90 bytes = ~2.2 GB | fits one Redis node; sharding is for throughput and blast radius, not memory |
| Monthly board history | 12 boards x 2.2 GB = ~26 GB/year | snapshot old boards to Postgres, keep only current in Redis |
3API design
POST /api/v1/scoresSubmit score event: {player_id, match_id, score}. Idempotent on (player_id, match_id).
GET /api/v1/leaderboard/top?n=10&board=2026-08Top N players with scores; served from cache.
GET /api/v1/players/{id}/rank?board=2026-08Exact rank, score, and percentile for one player.
GET /api/v1/players/{id}/neighbors?radius=5The 5 players above and below the given player.
4High-level design
The core data structure is a Redis sorted set (ZSET): member = player_id, score = points. ZINCRBY updates a score in O(log N), ZREVRANK returns exact rank in O(log N), ZREVRANGE returns top-K in O(log N + K). One command each for every product feature is why this problem is a Redis showcase; a SQL ORDER BY with OFFSET recomputes a sort or walks an index per query and cannot give cheap exact rank.
Writes flow through a score service: the game server (never the client) posts a signed score event, the service checks idempotency on (player_id, match_id) in Postgres, appends the event to a durable events table, then applies ZINCRBY. Postgres is the source of truth; Redis is a rebuildable projection. If Redis dies, replay events to reconstruct the board.
Reads split by pattern. Top-10 is served from a 1 second in-process cache in the API layer since millions of users see the identical payload; this absorbs the hottest traffic for free. Exact rank and neighbors go to Redis directly. Neighborhood is ZREVRANK to find the player's rank r, then ZREVRANGE r-5 to r+5.
At larger scale, shard the sorted set by hash(player_id) across M Redis nodes. Any player's node is known, so ZINCRBY stays a single-node op. Top-K becomes scatter-gather: fetch top K from every shard and merge K x M candidates, cheap for K=10. Exact global rank is the hard part: sum ZREVRANK-style counts of players above score s across all shards (ZCOUNT s +inf per shard), which is M round trips done in parallel.
Monthly reset is just a key naming scheme: leaderboard:2026-08. A cron snapshots the closing board into Postgres (rank, player, score rows), then traffic moves to the new key. Old boards are served from Postgres since they are immutable.
5Data model
score_events
event_id, player_id, match_id, score, signature, created_atappend-only source of truth; unique index on (player_id, match_id) gives idempotency
redis: leaderboard:{YYYY-MM}
ZSET member=player_id score=total_pointsrebuildable projection of score_events
board_snapshots
board_id, rank, player_id, score, snapshot_atclosed monthly boards, immutable, served from Postgres
players
player_id, handle, region, created_at, trust_scoretrust_score feeds anti-cheat review
6Deep dives
Top-K vs exact rank: know which one you are building
Top-K and exact rank look like the same feature but have wildly different costs, and interviewers probe this. Top-10 is trivially cacheable (one payload for all users), tolerates a second of staleness, and even in a sharded world is a cheap K x M merge. Exact rank for an arbitrary player is per-user, uncacheable, and under sharding requires aggregating counts across every shard.
If the product only needs top-K plus a rough position, you can skip exact rank entirely: show percentile instead, computed as players_above / total, where players_above comes from a per-shard ZCOUNT summed lazily every few seconds. Many real games do exactly this ('top 3%') because users cannot tell rank 1,204,113 from 1,206,551.
If exact rank is required at huge scale, an alternative to fan-out is range-partitioning by score band with periodic rebalancing, so rank = count in higher bands (maintained counters) + local rank within the band. It trades write-time complexity (band migrations) for O(1)-ish rank reads. Mention it, then say hash-shard fan-out is simpler and fine at M under ~20 shards.
Sharding a sorted set without breaking semantics
Hash-sharding by player_id keeps every write and every per-player read single-node, which preserves Redis's O(log N) magic where it matters. The operations that break are the global ones: top-K needs scatter-gather merge, and global rank needs cross-shard counting. Both are embarrassingly parallel, so latency is max-of-shards rather than sum, but tail latency now follows your slowest shard, so keep shards uniform and use consistent hashing so adding a shard reshuffles only 1/M of players.
A tempting wrong answer is range-sharding by score (shard 1 holds top players, etc.). It makes top-K a single-shard read but every score update can migrate a player across shards, and score distributions are heavily skewed so shards go hot. Only consider it with the band-counter scheme above, and say why.
Also note when NOT to shard: 25M members is ~2.2 GB, comfortably one node. Shard for write throughput, isolation, or failure blast radius, not memory. Saying 'this fits on one Redis and here is the number' is a strong interview move.
Anti-cheat and score integrity
A leaderboard invites cheating, and the design must assume the client is hostile. Rule one: clients never submit scores. The authoritative game server computes the result and posts it with an HMAC over (player_id, match_id, score, timestamp) using a key the client never sees. The score service verifies the signature and rejects stale timestamps to stop replay.
Idempotency doubles as anti-abuse: the unique (player_id, match_id) constraint means a captured request replayed 1,000 times counts once. On top of that, run anomaly detection on the event stream: z-score of points per match against the player's history and the global distribution, impossible session rates (50 matches an hour), and score deltas exceeding the game's theoretical max. Flag, do not auto-ban.
For flagged players, use shadow removal: ZREM them from the public board while their events keep accruing in Postgres. If the appeal succeeds, replay their events to restore the exact score. This is another payoff of keeping the durable event log separate from the Redis projection.
Failure modes and rebuild story
Redis persistence (AOF everysec) can still lose the last second of writes on a crash, which is why Postgres holds the events. Recovery is: promote a replica for reads, then reconcile by replaying events since the replica's last applied event. Track a per-shard high-water mark (last event_id applied) in Redis itself so replay knows where to resume, making rebuilds idempotent.
Dual-write consistency between Postgres and Redis is the other classic trap. Writing Postgres then Redis means a crash in between leaves Redis stale; that is acceptable here because the projection is rebuildable and a background reconciler sweeps recent events comparing applied marks. What you must not do is write Redis first: a score visible on the board that never durably existed is a much worse failure for a game economy than a briefly stale rank.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Redis sorted sets + Fastify + Postgres (events table) on a $15 VPS; k6 for load testing
- 01docker compose up redis and postgres; create score_events with a unique index on (player_id, match_id)
- 02Build POST /scores: verify HMAC, INSERT event (ON CONFLICT DO NOTHING), and ZINCRBY only when the insert added a row
- 03Build GET /leaderboard/top with ZREVRANGE WITHSCORES behind a 1 second in-process cache
- 04Build GET /players/:id/rank using ZREVRANK and ZSCORE, plus percentile via ZCARD
- 05Build the neighbors endpoint: ZREVRANK then ZREVRANGE rank-5 to rank+5
- 06Add a rebuild script that replays score_events into a fresh ZSET and diffs against the live one
- 07Add monthly key naming (leaderboard:YYYY-MM) and a snapshot script that dumps the closing board to Postgres
- 08Load test with k6 at 5k writes/sec and 50k reads/sec; verify p99 and that duplicate match_ids never double-count
Idempotent score submit with HMAC verification
typescriptimport { createHmac, timingSafeEqual } from "crypto";
import Redis from "ioredis";
import { Pool } from "pg";
const redis = new Redis();
const pg = new Pool();
const KEY = process.env.SCORE_HMAC_KEY as string;
function boardKey(d = new Date()): string {
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
return "leaderboard:" + d.getUTCFullYear() + "-" + m;
}
export async function submitScore(
playerId: string, matchId: string, score: number, ts: number, sig: string
): Promise<{ applied: boolean }> {
const payload = playerId + "|" + matchId + "|" + score + "|" + ts;
const expected = createHmac("sha256", KEY).update(payload).digest();
const given = Buffer.from(sig, "hex");
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
throw new Error("bad signature");
}
if (Math.abs(Date.now() - ts) > 60_000) throw new Error("stale event");
// Unique index makes this the idempotency check AND the durable record.
const res = await pg.query(
"INSERT INTO score_events (player_id, match_id, score, created_at) " +
"VALUES ($1, $2, $3, now()) ON CONFLICT (player_id, match_id) DO NOTHING",
[playerId, matchId, score]
);
if (res.rowCount === 0) return { applied: false }; // duplicate, already counted
await redis.zincrby(boardKey(), score, playerId);
return { applied: true };
}Rank, top-K, and neighborhood reads
typescriptimport Redis from "ioredis";
const redis = new Redis();
let topCache: { at: number; data: unknown } | null = null;
export async function topK(board: string, k = 10) {
if (topCache && Date.now() - topCache.at < 1000) return topCache.data;
const flat = await redis.zrevrange(board, 0, k - 1, "WITHSCORES");
const data = [];
for (let i = 0; i < flat.length; i += 2) {
data.push({ rank: i / 2 + 1, playerId: flat[i], score: Number(flat[i + 1]) });
}
topCache = { at: Date.now(), data };
return data;
}
export async function playerRank(board: string, playerId: string) {
const [rank, score, total] = await Promise.all([
redis.zrevrank(board, playerId),
redis.zscore(board, playerId),
redis.zcard(board),
]);
if (rank === null) return null;
return {
rank: rank + 1,
score: Number(score),
percentile: Math.round((1 - rank / total) * 1000) / 10,
};
}
export async function neighbors(board: string, playerId: string, radius = 5) {
const rank = await redis.zrevrank(board, playerId);
if (rank === null) return [];
const start = Math.max(0, rank - radius);
const flat = await redis.zrevrange(board, start, rank + radius, "WITHSCORES");
const out = [];
for (let i = 0; i < flat.length; i += 2) {
out.push({ rank: start + i / 2 + 1, playerId: flat[i], score: Number(flat[i + 1]) });
}
return out;
}Rebuild the board from the event log
sql-- Source of truth: replaying this reconstructs Redis exactly.
CREATE TABLE score_events (
event_id BIGSERIAL PRIMARY KEY,
player_id TEXT NOT NULL,
match_id TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (player_id, match_id) -- idempotency: replays cannot double-count
);
-- Totals to feed ZADD during a rebuild (batch in pages of 10k).
SELECT player_id, SUM(score) AS total
FROM score_events
WHERE created_at >= date_trunc('month', now())
GROUP BY player_id
ORDER BY player_id;
-- Reconciliation spot-check: compare against ZSCORE for sampled players.
SELECT player_id, SUM(score) AS total
FROM score_events
WHERE created_at >= date_trunc('month', now())
AND player_id = ANY(:sampled_ids)
GROUP BY player_id;Bottlenecks & failure modes
- ⚠Top-10 hot key hammering one Redis shard; absorb with short-TTL edge or in-process caching since the payload is identical for everyone
- ⚠Cross-shard fan-out for exact global rank makes tail latency the max of the slowest shard; keep shard count modest and query in parallel
- ⚠Idempotency check in Postgres sits on the write path; a unique-constraint insert doubles as check and record in one round trip
- ⚠Tournament spikes are write bursts to a few contested boards; queue score events and apply asynchronously if Redis CPU saturates
- ⚠Monthly rollover thundering herd when a new key starts cold; pre-create the key and warm the top-10 cache before cutover
Key takeaways
- ▸Redis sorted sets give O(log N) update, exact rank, and top-K, which maps one-to-one onto leaderboard features
- ▸Keep a durable append-only event log as source of truth and treat Redis as a rebuildable projection
- ▸Top-K and exact rank have different costs; cache the former, consider percentile instead of the latter
- ▸Shard by player hash to keep writes single-node; global queries become parallel scatter-gather
- ▸Anti-cheat is server-authoritative scores, signed events, idempotency keys, and shadow removal