Leaderboard
0-day streakReview

Your progress

Level 1

0 XP100 XP to level 2
0day streak
0best streak
0topics started
0cards reviewed
    Review cards

    Progress is stored only in this browser. No account is required.

    Layer 4 · Full breakdown · 33 min · 11 figures

    Design a live leaderboard

    Design a live leaderboard for tens of millions of players: Redis sorted sets and skip lists, fair tie-breaking inside a double, sharding and approximate ranks, rebuildable boards, midnight resets and live top-10 updates.

    A leaderboard looks like the simplest screen in a game: a sorted list of names and numbers. Behind it, three questions are asked millions of times a minute — who is on top, where am I, and who is just ahead of me — over a list that never stops changing. On an IPL final night, a cricket game or a fantasy contest has millions of people refreshing the same board while scores land every second, and the player at rank 4,931 cares about rank 4,930 as much as anyone cares about rank 1. The same shape shows up in a global puzzle game’s weekly league. You will design the leaderboard service for a mobile game with 50 million players: game servers submit scores; players see the top 100, their own rank and their neighbours; boards reset daily and weekly at midnight IST; friends get their own board; and featured contests show a live top 10. The interesting parts are why a database cannot cheaply answer “what is my rank?”, how a skip list can, how to break ties inside a 64-bit double, and what to do when one sorted set on one server is no longer enough.

    Understand the problem

    A leaderboard is a ranking index that is written constantly and read in three shapes: a slice from the top, one player’s position, and a slice around it. Weak answers pick a database first and discover the third shape late.

    Functional requirements

    1. Game servers should be able to submit a player’s score at the end of each match. The authoritative game server sends it, not the player’s phone. Depending on the board, a score either replaces the entry if it is a new best (a high-score board) or adds to it (a points-this-season board).
    2. Users should be able to see the top K players — the top 100 by default — with names, avatars and scores, fresh within a couple of seconds. For featured contests, the top 10 updates live on screen.
    3. Users should be able to see their own rank and the players around them: “you are #4,931; here are #4,926 to #4,936”.
    4. Users should be able to switch between time windows — today, this week, this season — where days and weeks reset at midnight IST (a time zone configured per game), and each period’s final standings are kept.
    5. Users should be able to see a friends leaderboard: their 200 or so friends ranked against each other.

    Non-functional requirements

    • Scale. 50 million players with a season score, 20 million daily active, and up to 2 million people watching one featured contest’s live top 10 on an IPL final night.
    • Write rate. About 8 matches per active player a day: 160 million score events a day, around 10k a second at the evening peak, and bursts of 50k a second in a weekly tournament’s final minute.
    • Read rate. About 12k leaderboard screen views a second at peak, each needing the top 100 and the viewer’s own rank.
    • Freshness. A new score is reflected in ranks within 2 seconds at p99. During bursts the long tail may lag by up to a minute, but the top 100 must stay within 2 seconds.
    • Latency. Read APIs answer in under 10 ms at p50 and 50 ms at p99, measured at the service.
    • Availability and durability. 99.95% for reads. An accepted score event is never lost: the board may be briefly stale, but it must not silently drop a score.
    • Correctness. The top 100 and every rank in the top 10,000 are exact. Deeper ranks may be approximate (“top 3%”) within about 1%. Ties are resolved deterministically: whoever reached the score first ranks higher.

    In the interview

    Ask before choosing storage: “Is a score a personal best or a running total?” (set-if-greater or increment). “How big is the biggest board, and how often do scores change?” “Must the player ranked 12 millionth see an exact number?” “When do boards reset, in which time zone?” And always: “Do trusted servers submit scores, or the client?”

    Out of scope

    • Matchmaking. It pairs players by skill rating, not leaderboard rank, and is a separate system.
    • Anti-cheat models. We enforce server-authoritative scores and per-match limits; behavioural cheat detection is a follow-up.
    • Payments and prizes. We export final standings; payouts run in a payments system with its own ledger.
    • Profiles and the friend graph. We read names, avatars and friend lists from the services that own them.

    The set-up

    Plan the approach

    Satisfy each requirement simply, then spend the time in the deep dives. Say the shape early: “a write path that turns match results into durable score events, an in-memory ranking index that answers all three read shapes in logarithmic time, and a fan-out tier for live viewers.” The deep dives follow from the numbers.

    Core entities

    • Player. player_id, display name, avatar, country — owned by the profile service.
    • Board. What is ranked (best score or running total), the period type (daily, weekly, season), the reset time zone and the tie rule. A board instance is one board for one period: season-S7, daily-2026-10-27, weekly-2026-W44.
    • Score event. Immutable: event_id (match plus player), player_id, points, ended_at, and which game server sent it.
    • Board entry. One row per board instance and player — score, when it was reached, last event applied. This is the source of truth.
    • Ranking index. The derived, in-memory sorted set for each board instance, rebuilt from board entries whenever needed.
    CREATE TABLE board_entries (
      board_instance  text,         -- 'season-S7', 'daily-2026-10-27'
      player_id       text,
      score           bigint,
      achieved_at     timestamptz,  -- when this score was first reached
      last_event_id   text,         -- makes re-applying an event a no-op
      PRIMARY KEY (board_instance, player_id)
    );

    Any store with conditional writes works — PostgreSQL sharded by player, Cassandra, DynamoDB. It is read by key, never ranked.

    API

    Submitting a score is server-to-server, and is accepted once the event is durable, before any board is updated.

    POST /v1/scores
    Authorization: Bearer <game-server token>
    
    {
      "event_id": "m_77812:p_8812",
      "player_id": "p_8812",
      "match_id": "m_77812",
      "points": 1350,
      "ended_at": "2026-10-27T21:14:05+05:30"
    }
    
    HTTP/1.1 202 Accepted
    { "event_id": "m_77812:p_8812", "status": "accepted" }

    Reading boards. board is a board instance or an alias such as daily-today, which the server resolves in the board’s time zone.

    GET /v1/boards/season-S7/top?limit=100
    GET /v1/boards/season-S7/players/me?around=5
    GET /v1/boards/season-S7/friends
    {
      "board": "season-S7",
      "as_of": "2026-10-27T21:14:06.200+05:30",
      "me": { "rank": 4931, "score": 48250 },
      "around": [
        { "rank": 4926, "player_id": "p_3310", "name": "Riya", "score": 48410 },
        { "rank": 4931, "player_id": "p_8812", "name": "You", "score": 48250 },
        { "rank": 4936, "player_id": "p_3392", "name": "Tomás", "score": 48120 }
      ]
    }

    Deep in the tail, me becomes { "approx_rank": 12400000, "top_percent": 24.8 } — more on that in the deep dives.

    Watching a featured board live uses a WebSocket or server-sent events: a snapshot first, then versioned deltas at most once a second.

    { "type": "snapshot", "board": "contest-ipl-final", "version": 41, "top": [ ... ] }
    { "type": "delta", "board": "contest-ipl-final", "version": 42, "base": 41,
      "changes": [ { "rank": 3, "player_id": "p_0571", "score": 9120 } ] }

    Back-of-envelope estimates

    Show the arithmetic; these numbers decide whether one Redis node is enough. (For the method, see back-of-envelope estimation.)

    Score events. 20M daily players × 8 matches = 160M events a day. 160M ÷ 86,400 s ≈ 1.9k events/s on average. The evening, roughly 19:30 to 23:00 IST, runs at about 5× that: ≈ 10k events/s. The final minute of a weekly tournament: design for 50k events/s for 60 seconds.

    Sorted-set writes. Each event touches four board instances — daily, weekly, season and country-season — so 10k × 4 = 40k writes/s at the evening peak and 50k × 4 = 200k writes/s in a burst.

    Reads. 20M daily players × 10 leaderboard views = 200M views a day ≈ 2.3k/s, about 12k/s at a 5× peak. The top 100 comes from a one-second cache; the viewer’s rank and neighbours need 2 Redis calls: 12k × 2 = ≈ 24k Redis reads/s.

    Memory per entry. A skip-list node holds an 8-byte score, an 8-byte back pointer and 16 bytes per level (forward pointer plus span); at 1.33 levels on average that is 8 + 8 + 21 ≈ 37 bytes. Add the embedded 12-byte member ID with its header, about 16 bytes, for ≈ 53, which the allocator rounds up to about 64. The member-to-score hash table adds about 32. Call it ≈ 100 bytes per entry.

    Memory per board. Season: 50M × 100 B = 5 GB. Country-season boards (every player is in exactly one): another 5 GB. Weekly: about 35M players × 100 B = 3.5 GB. Daily: 20M × 100 B = 2 GB. Total ≈ 15.5 GB, about 20 GB while yesterday’s and last week’s boards are kept for their grace period. It fits on one server.

    CPU per operation. Pugh’s analysis puts a skip-list search at about (log base 1/p of n) ÷ p + 1/(1 − p) steps. Redis uses p = 1/4, and log₄(50M) ≈ 12.8, so 12.8 × 4 + 1.33 ≈ 52 steps — about 5 µs if most steps miss the CPU cache at roughly 100 ns. Add protocol overhead and budget about 50k sorted-set writes/s per primary to keep CPU near 50%: the 40k/s peak fits, barely; the 200k/s burst does not.

    Live fan-out. 2M viewers × 1 delta a second × about 300 bytes = 600 MB/s ≈ 4.8 Gbit/s of egress. At about 50k connections per WebSocket gateway, 2M ÷ 50k = 40 gateways.

    What the numbers decide: memory is not the problem; one thread per sorted set is. Reads can be cached and replicated, writes cannot. And the live board is a fan-out problem, not a ranking problem.

    Tip

    For leaderboards, the number that matters is not how many players you store but how many rank queries you answer, times what one costs. A database stores 50 million scores easily; it cannot count 25 million of them on every screen view.

    High-level design

    Build the design one requirement at a time; by the end it works for one region at the evening peak.

    Game servers submit scores

    The player’s phone never reports its own score — a modified client would post whatever it liked. The game server that refereed the match sends the result. Google Play Games lets developers set minimum and maximum allowed scores to discard obviously fraudulent submissions; the score service applies similar per-match limits.

    Step 1: the game server submits the result; the score service records it and updates one sorted set per board.Notice The score lives in two places with different jobs: the database is the truth, the sorted set is a fast, ordered copy.

    The score service does four things per event:

    1. Authenticate and validate. Is this a known game server? Did the match exist, was the player in it, and are the points within the per-match limit?
    2. Compute the new entry for each board. For a best-score board, keep the larger of old and new; for a running total, add.
    3. Write the board entry in the scores database with a conditional update on last_event_id, so a retried event changes nothing.
    4. Update the ranking index: ZADD lb:season-S7 48250 p_8812 sets the member’s score and moves it to its sorted position.

    That last step is why the index is a Redis sorted set: unique members, each with a numeric score, kept in score order on every write — leaderboards are the documented typical use.

    Users see the top K

    The leaderboard service answers GET /top with ZREVRANGE lb:season-S7 0 99 WITHSCORES: the 100 highest members with scores, in O(log N + M) for M results — microseconds. It then fetches names and avatars for those 100 players in one batched call to a profile cache; the sorted set holds IDs, not display data.

    Twelve thousand people a second asking for the same 100 rows is a caching problem, not a ranking one: each service instance keeps the assembled response for one second, so Redis sees one call per second per instance per board.

    Users see their rank and the players around them

    Two commands answer “where am I?”. ZREVRANK lb:season-S7 p_8812 WITHSCORE returns the zero-based position from the top, and the score, in O(log N) — WITHSCORE arrived in Redis 7.2. Then ZREVRANGE lb:season-S7 4925 4935 WITHSCORES returns the eleven players around that position. A position range jumps straight to the start, so it costs O(log N + 11), not a walk from the top.

    The three read shapes on one sorted set: a slice from the top, one player’s position, and a slice around that position.Notice All three are logarithmic: the set is already sorted, so no read ever sorts or counts.

    The second call needs the first one’s answer, so that is two round trips of about 0.3 ms, or one inside a short Lua script. Both are reads and go to a replica; a rank a few milliseconds stale is fine. A player with no score this period gets nil and a “play a match to get ranked” message.

    Users switch between time windows

    Every period gets its own key: lb:daily-2026-10-27, lb:weekly-2026-W44, lb:season-S7. The score service maps each event to its periods from the match’s ended_at in the board’s time zone — Asia/Kolkata here — and writes every current instance. Nothing is reset at midnight: tomorrow’s key does not exist until its first ZADD, and yesterday’s is archived and deleted later. The sharp edges are in the deep dives.

    Users see a friends leaderboard

    The tempting design is a sorted set per user of their friends’ scores. Then every score update is written into every friend’s set: 200 friends × 10k events/s = 2M writes/s, for lists that are rarely opened. Compute it on read instead: fetch the ~200 friend IDs from the social graph, call ZMSCORE lb:season-S7 f1 f2 … f200 — one round trip, O(200) — and sort 200 numbers in the service. About a millisecond; cache it per user for 10 seconds.

    Step 2: the working design for one region — writes through the score service, reads through the leaderboard service from a replica.Notice The asymmetry: reads are cached and spread over replicas, but every write for a board lands on one primary.

    You now have a working leaderboard for one region at the evening peak. The deep dives are about what breaks: the database that cannot rank, ties, a board that outgrows one thread, a failover, midnight and two million live viewers.

    Deep dives

    Why can’t the database just rank players?

    Start where most candidates start: a table of scores with an index on the score.

    -- Top 100: fine. The index hands back the first 100 entries.
    SELECT player_id, score FROM board_entries
    WHERE board_instance = 'season-S7'
    ORDER BY score DESC LIMIT 100;
    
    -- My rank: counts every entry above mine.
    SELECT COUNT(*) + 1 FROM board_entries
    WHERE board_instance = 'season-S7' AND score > 48250;

    The first query is cheap; the second is the problem. A B-tree finds the starting point in O(log N), but it does not store how many entries sit under each branch, so counting the players above you means walking past every one of them — 25 million index entries for a median player. Even at an optimistic 10 million entries a second per core, that is 2.5 seconds of CPU per screen view, and 12k views a second would need 30,000 cores. AWS’s leaderboard write-up measured its nested rank query on 50 million rows at about 35 seconds on average, even with indexes. Caching cannot help: the answer changes whenever anyone above you scores.

    Work needed to answer one “what is my rank?” query on a 50-million-player board (log scale).Notice SQL is only cheap for players near the top; the sorted set costs the same 50-odd steps for everyone.

    Bad: rank in SQL on every read. Correct and simple, but the cost grows with the player’s rank, so it fails for everyone outside the top 100.

    Good: recompute ranks in a batch. Sort the board every few minutes and write a rank column. A sort of 50M rows is about 50M × log₂(50M) ≈ 50M × 25.6 ≈ 1.3 billion comparisons: fine hourly, not live. You gain cheap reads; you pay minutes of staleness, and the player who just scored sees the old rank.

    Great: an order-statistics index in memory. You need a structure that is ordered by score and knows positions, so inserts, updates and rank lookups are all logarithmic. A balanced tree with subtree sizes does this; a Redis sorted set does it with two structures over the same members:

    • A hash table maps member to score, so ZSCORE is O(1) and an update can find the member’s current node.
    • A skip list keeps members ordered by score, then by member bytes. William Pugh introduced skip lists in 1990 as a probabilistic alternative to balanced trees: every node sits on level 1 and is promoted to each next level with probability p, so upper levels become express lanes. A search starts at the top and drops a level whenever the next hop would overshoot. Redis uses p = 1/4, capped at 32 levels — Pugh’s recommendation, at about 1.33 pointers per element.

    The detail that makes ranks cheap is a Redis addition: every forward pointer also stores a span, the number of positions it jumps. Add up the spans along the search path and you have the position, without counting anybody.

    A skip list drawn highest score first. Upper-level arrows show their spans (every level-1 span is 1); the search for 77 sums 4 + 2 + 1.Notice The search never visits the six players it jumps over, yet it knows exactly how many it skipped.

    An update removes the node and re-inserts it at its new position — two logarithmic operations, fixing spans on the way. ZREVRANGE start stop uses the spans to jump straight to position start, which is why the neighbours query costs the same at rank 4,926 or 24 million. One trap: ZRANGE … BYSCORE … LIMIT offset count walks past offset members one by one, which the documentation warns can approach O(N); page by position, not by score offset.

    Below 128 members (the default zset-max-listpack-entries), Redis stores a sorted set as one compact listpack instead — handy for thousands of tiny per-clan boards. Keep SQL as the source of truth and for reports; answer every rank question from a sorted set.

    How do we break ties fairly?

    Look back at the board: p_2238 and p_8812 both have 48,250. Redis orders equal scores by member bytes, so in a descending range p_8812 comes first purely because “8” sorts after “2” — deterministic but arbitrary, and gameable if members were vanity names. The product rule is “whoever got there first ranks higher”.

    Bad: put the tie-breaker in the member. Make the member 0907200:p_8812, so equal scores sort by time. The ordering works, but the member changes whenever the score changes, so every update must first find and remove the old member. You lose the single, idempotent ZADD the rest of the design depends on.

    Good: shared ranks. Show standard competition ranking — 1, 2, 2, 4 — with rank = ZCOUNT lb:season-S7 (48250 +inf plus one, in O(log N). Honest, no encoding. You pay when something must be decided: a first prize with two people on it, or a “top 100” that holds 103 players.

    Great: a composite score. Pack the points and a tie-breaker into one number, so that more points always wins and, at equal points, earlier wins:

    composite = points × 2²⁵ + (2²⁵ − 1 − t), where t is seconds since the season started.

    The low part shrinks as time passes, so an earlier achiever has the larger composite; decode with points = ⌊composite ÷ 2²⁵⌋. The limit is the container: a Redis score is a 64-bit IEEE 754 double with 52 stored fraction bits plus an implicit leading 1, so 53 significant bits, and the ZADD documentation confirms integers are exact only between −2⁵³ and +2⁵³. Budget the 53 bits:

    • Time. A season of up to 365 days is 31,536,000 seconds, which fits in 25 bits: 2²⁵ = 33,554,432 seconds, about 388 days.
    • Points. That leaves 53 − 25 = 28 bits, up to 2²⁸ − 1 = 268,435,455 points. A player capped at 10,000 points a match, 8 matches a day for a 120-day season, reaches at most 9.6 million.
    • The top fits. (2²⁸ − 1) × 2²⁵ + 2²⁵ − 1 = 2⁵³ − 1, so the largest possible composite is exact.
    Packing points and an inverted timestamp into the 53 exact bits of a double — and the decimal version that rounds without telling you.Notice The broken version raises no error: the score is accepted, only quietly off by 18 milliseconds.

    The trap is the “obvious” decimal version: points × 10¹³ plus a millisecond timestamp. With 48,250 points that is about 4.8 × 10¹⁷, far above 2⁵³ ≈ 9.0 × 10¹⁵. Between 2⁵⁸ and 2⁵⁹ consecutive doubles are 2⁶ = 64 apart, so the value is rounded to a multiple of 64, and players who finished within 64 ms of each other can tie or swap. Nothing fails; the ranking is just wrong.

    Choose the resolution per board: a daily board needs only 86,400,000 milliseconds, which fits in 27 bits (2²⁷ = 134,217,728), leaving 26 bits — 67 million points — for millisecond tie-breaks. Store the layout in the board’s config so every reader decodes alike. And since the tie-breaker must record when the new total was reached, a running total cannot ZINCRBY by points × 2²⁵, which keeps the old time; the writer computes the full composite and sends it with ZADD — what the durability deep dive wants anyway.

    Doubles round silently

    Any score scheme that multiplies or concatenates numbers must stay at or below 2⁵³. Check the largest possible value, not today’s largest value: the season leader in week 16 will be the first to break it.

    How do we scale beyond one Redis node?

    One fact surprises people: Redis Cluster does not split a key. It hashes each key to one of 16,384 slots, each slot lives on one primary, and every command on that key runs on that primary’s single thread. So lb:season-S7 — all 50 million members — is one key on one core, however many nodes you add. Replicas absorb reads, but every write lands on that primary: at 40k writes a second you are at the budget, and the 200k-a-second burst is 4× over. To scale writes, split the board into several keys yourself.

    Good: shard by score range. Shard 1 holds 60,000 points and up, shard 2 holds 30,000 to 59,999, shard 3 the rest. The top 100 comes from shard 1, and your rank is the size of each higher shard (a cached ZCARD) plus your local ZREVRANK — one call. You pay three ways. Skew: most players have low scores, so the bottom shard takes most writes, and a daily board starts with everyone at zero. Migration: a player crossing 30,000 moves between nodes non-atomically, briefly in both or neither. Drift: scores rise all season, so boundaries keep moving, relocating millions of members.

    Good: shard by player. Put each player in shard hash(player_id) mod 8 — keys lb:season-S7:{0} to lb:season-S7:{7}, whose hash tags pin each key to a known slot that you place on its own primary. Writes spread evenly, 200k ÷ 8 = 25k a second per shard, and every query becomes an exact scatter-gather:

    • Top 100. Ask each shard for its top 100 and merge the 800 entries. Anyone in the global top 100 is also in their own shard’s top 100, so the merge is exact. Cache it for a second, as before.
    • My rank. One plus the sum, over shards, of ZCOUNT shard (my_score +inf: 8 parallel O(log n) calls.
    • Neighbours. From each shard, the 5 members just above and just below my score (ZRANGE … BYSCORE … LIMIT 0 5, once in each direction); merge and keep the closest 5 on each side.

    You pay in fan-out: a rank read’s p99 is the slowest of 8 shards, and 24k rank reads a second become 8 × 24k = 192k shard calls — 24k per shard, spread over its replicas. Use more logical shards than nodes, say 64 keys on 8 primaries, so you grow by moving whole keys rather than rehashing players.

    Great: exact where people look, approximate where they cannot tell. Nobody can tell 12,400,000th from 12,400,350th. Keep exact ranks for the top 10,000 through the player shards, and answer everyone below from a score histogram:

    • Split the score range into 10,000 buckets, with edges at quantiles of last night’s distribution, so each starts the day holding about 50M ÷ 10,000 = 5,000 players. Counts drift as scores move; the counters track that.
    • When a player’s score moves into a new bucket, the processor runs HINCRBY −1 on the old bucket and +1 on the new one.
    • Once a second, each leaderboard service instance loads the counters with one HGETALL, builds prefix sums, and answers any rank by binary search: players in higher buckets plus an interpolated share of yours. No Redis call per request.
    • The error is at most one bucket, ±5,000 players: 0.04% at rank 12.4 million, and 1% at rank 500,000. The screen says “≈ 12.4M · top 24.8%”.
    Three ways to split one 50-million-player board, and what a rank lookup costs in each.Notice Only the player-hash split spreads writes evenly without moving anyone; the histogram trades exactness for zero per-request work.
    One sorted set Shard by score range Shard by player Histogram for the tail
    Writes All on one primary Skewed to busy score bands Spread evenly Two counter updates when a bucket changes
    Top 100 1 call 1 call to the top shard N calls, merge N × 100 Not answered — pair it with a top tier
    My rank 1 call, exact 1 call + cached shard sizes, exact N parallel calls, exact In-memory lookup, ± one bucket
    Rebalancing None Move boundaries as scores drift Move whole logical shards Recompute bucket edges nightly
    Failure blast radius The whole board One score band 1/N of players Small: a stale counter
    Splitting a board that outgrows one thread

    The recommendation: one sorted set per board until it nears about 50k writes a second; then 8 to 16 player shards for an exact top 100 and exact ranks down to 10,000; and the histogram for everyone below. Since 10,000 of 50 million is 0.02% of players, almost every rank read is answered from memory with no fan-out. Score-range sharding pays off only when the distribution is stable, such as an all-time board with years of history.

    How do we keep scores durable and rebuild the board?

    Redis is memory first, and that has consequences. Replication is asynchronous: the Redis Cluster specification says plainly that a primary can acknowledge a write and fail before any replica receives it, and the promoted replica never learns of it. With append-only-file persistence syncing once a second, a crash can also lose about a second of writes. A leaderboard whose only copy of the scores is Redis will one day silently lose points — and the player who loses first place to a failover will tell everyone.

    Bad: Redis as the only store, updated with ZINCRBY from the game server. One retry later, a player has double points; one failover later, someone’s points are gone — undetectably.

    Good: database first, then Redis, from the score service. The database is the truth and Redis can be rebuilt from it. But it is a dual write: if the service dies between the two, Redis misses the update until the next full rebuild.

    Great: log first, derived views second. The score service appends the event to a Kafka topic, score-events, keyed by player_id with acks=all, and returns 202 once the broker has it. Score processors consume each partition in order and, for every event:

    1. Apply it to the database with a conditional write that skips events already applied — the entry’s last_event_id, a short per-player set of recent event IDs, or a per-player sequence number from the game server.
    2. Send the absolute composite score with ZADD, never an increment, to every board instance. Best-score boards add GT, so a late or replayed older event can never lower a score.
    3. Commit the offset.

    Everything after the log is idempotent, so the cure for any doubt is to replay.

    The log is the record; the database and the sorted sets are derived from it, and replaying is always safe.Notice After a Redis failover the processor simply rewinds and replays: events already applied change nothing, and the lost ones are written again.
    • Repair after a failover. Processors rewind their offsets to a checkpoint about five minutes back and replay. Already-applied events leave the database untouched and rewrite the same absolute scores; only the lost writes change anything.
    • A full rebuild. If a board is corrupted or its layout changes, build a new key from the database, pipelining ZADD with 1,000 members per command. At a few hundred thousand members a second, 50M members take a few minutes (50M ÷ 250k per second = 200 s). Then RENAME the new key over the live one, so readers switch atomically. In Redis Cluster both names must share a slot, so give them the same hash tag — lb:season-S7:{3}:next and lb:season-S7:{3} — and enable lazyfree-lazy-server-del so the old 5 GB set is freed in the background.
    • Ordering. Keying by player keeps each player’s events in order; across players, order does not matter.
    • Bursts. In a tournament’s final minute, game servers still get their 202 in milliseconds while processors work through the queue. Processors coalesce each micro-batch of about 200 ms to the latest absolute score per player and board (safe because ZADD is absolute), and events that would enter the top 1,000 — above a cached threshold — go to a small priority topic processed first. The top of the board stays within 2 seconds; the long tail catches up within a minute.

    This is CQRS in miniature: the log is the record, the database is the queryable truth, and the sorted sets are read models you can throw away and rebuild.

    How do daily and weekly boards roll over?

    Midnight is when naive leaderboards fall over. Google Play Games resets daily boards at UTC−7 all year round, and weekly boards at midnight between Saturday and Sunday in that zone. This game resets at 00:00 IST, 18:30 UTC. India has no daylight saving, so every day is exactly 24 hours; a zone with daylight saving brings 23- and 25-hour days, a good reason to pin resets to a fixed UTC offset.

    Bad: one key per board, and a reset job at midnight. At 00:00, delete lb:daily and start again. DEL on a sorted set is O(M) in its size: freeing 20 million members keeps the single Redis thread busy for something on the order of a second while every other command waits. At the same instant, every client’s cached “today” board expires together.

    Good: one key per period, with a TTL. Writes for 28 October go to lb:daily-2026-10-28; nothing happens at midnight, and yesterday’s key gets an EXPIRE. Two catches: expiring a big key also frees it synchronously unless lazyfree-lazy-expire is yes (the default is no), and a TTL neither keeps final standings nor handles matches still running at midnight.

    Great: period keys with a grace window, a finaliser and an archive.

    1. The match decides the period, not the arrival time. A match ending at 23:59:58 counts for 27 October even if its event arrives at 00:00:03. The game server stamps ended_at from an NTP-synced clock; processors reject future times and times older than the grace window.
    2. A grace window. Processors keep accepting events for 27 October until 00:10 IST — enough for in-flight matches and ordinary Kafka lag.
    3. Freeze and archive. At 00:10 a finaliser marks the instance frozen in board config (later events are logged and dropped), copies the top 10,000 with scores into a final_standings table for prizes and history, and exports the full board from the database to object storage.
    4. Free memory without blocking. UNLINK the key 48 hours later: O(1) on the main thread, memory reclaimed in the background. Turn on lazyfree-lazy-expire as a safety net.
    5. No herd at midnight. The daily-today alias flips on the server; per instance, the first cache miss for the new key computes it while others wait (single flight); live viewers get a fresh snapshot; clients refresh on their own jittered schedules.
    Four board instances across midnight IST. Only the daily board changes keys; nothing is deleted at midnight.Notice The ten-minute grace band: yesterday’s key still accepts late results while today’s key is already live.

    Keep the weekly board as one more key the processors write, rather than a ZUNIONSTORE of seven daily boards at Monday midnight: that command costs O(N) + O(M log M) over up to 7 × 20M = 140M entries and would block a primary for a long time. Use it for small boards or offline.

    How do we push live updates to two million viewers?

    On an IPL final night a featured contest can have 2 million people watching the same top 10. The ranking part is trivial — one ZREVRANGE 0 9 a second. The hard part is delivering the answer two million times.

    Bad: every client polls the API every second. The one-second cache protects Redis, but load balancers, TLS termination and API pods are now sized for 2M requests a second to show ten rows.

    Good: poll a CDN-cached endpoint. The top-10 response carries Cache-Control: max-age=1; the CDN absorbs the requests and each edge asks the origin about once a second. Stateless, and good enough for most boards. You pay in edge requests and battery, up to about 2 seconds of staleness, and nothing personal can be in the shared response.

    Great: push coalesced, versioned deltas.

    • One publisher per featured board, a leader-elected worker, reads the top 10 once a second, diffs it against the previous snapshot, and publishes {version 42, base 41, changes} to a pub/sub channel. Whether 6 or 6,000 scores changed, viewers get at most one message a second.
    • Gateways subscribe once per board. Each of the 40 WebSocket gateways fans the delta out to its roughly 50k local connections; the pub/sub layer sees 40 subscribers, not 2 million.
    • Versions make gaps safe. A client holding version 41 that receives base 43 has missed a delta and asks for a snapshot; new and reconnecting clients always start with one. With server-sent events, the browser reconnects by itself and sends Last-Event-ID.
    • Personal rank is targeted, not broadcast. Pushing everyone’s rank each second would be 2M ZREVRANK calls a second. Only the player whose score changed gets a “your rank changed” message; others refresh their rank every 30 seconds or on screen focus.
    Live top 10: one read a second, one delta per second per viewer, however many scores changed.Notice The ratio: fourteen score changes in three seconds become three messages, and Redis is read three times, not six million.

    SSE or WebSocket? SSE is one-way over plain HTTP with reconnection and resume built in — a natural fit for a viewer who only listens; use a WebSocket when the connection already carries game traffic (see long polling, SSE and WebSockets). Either way, budget about 4.8 Gbit/s of egress, and if more than half the top 10 changed, send a snapshot instead of a delta.

    In the interview

    Separate the two problems out loud: “Ranking two million viewers’ top 10 is one Redis read a second. Delivering it is a fan-out problem, so I coalesce to one versioned delta per second and fan it out through gateways that each subscribe once.”

    The final design

    Put the pieces together. Scores flow through a log into a database and a set of sharded sorted sets; reads come from replicas and caches; live viewers get one delta a second.

    The complete leaderboard service for one region, from match result to live top 10.Notice Nothing on the read path waits for a write, and every box after Kafka can be rebuilt by replaying the log.

    Follow one score, and one screen view, through the system:

    1. Submit. A game server posts the result to the score API, which authenticates it, checks per-match limits, appends the event to score-events with acks=all, and returns 202 within milliseconds.
    2. Apply. Score processors consume each partition in order, coalesce 200 ms micro-batches, and upsert each entry into the scores database, ignoring events already applied.
    3. Index. For each board instance — daily, weekly, season, country — the processor sends the absolute composite score with ZADD to the player’s shard (GT on best-score boards) and adjusts the histogram with HINCRBY. Would-be top-1,000 events take the priority lane.
    4. Read. The leaderboard API serves the top 100 merged from shards and cached for a second; exact ranks for the top 10,000 via parallel ZCOUNTs on replicas; a histogram percentile below that; neighbours with short ranges; friends with one ZMSCORE.
    5. Push. For featured boards, a publisher reads the top 10 each second and publishes a versioned delta; 40 WebSocket gateways push it to 2 million viewers.
    6. Roll over and repair. At 00:10 IST the finaliser freezes yesterday’s board, stores the top 10,000 as final standings, exports the board to object storage and unlinks it 48 hours later. After a Redis failover, processors rewind to a checkpoint and replay.

    Check it against the requirements. Scale: a 50M-entry board is about 5 GB, and 8 player shards take the 200k-writes-a-second burst at 25k each. Freshness: about a second normally, under 2 seconds for the top of the board even in bursts. Latency: top 100 from memory and ranks in a few sub-millisecond Redis calls, well under 10 ms at p50 and 50 ms at p99. Durability: a score is acknowledged only once it is in a replicated log, and everything after that is rebuildable. Correctness: exact top 10,000, deterministic tie-breaks within 53 bits, and an honest “top 24.8%” below.

    What interviewers look for by level

    Area Mid-level Senior Staff
    Requirements Top 10 and my score Best-score versus running-total boards; asks who submits scores Exactness tiers (exact top 10k, approximate tail) with freshness per tier
    Data structure “Use Redis” Explains why SQL rank is O(rank); sorted-set commands and costs Skip-list spans, listpack for small boards, the BYSCORE offset trap
    Ties Not discussed Shared ranks or a timestamp tie-break Composite score within 53 bits; knows the decimal-packing trap
    Scale Adds replicas Knows one key is one thread; shards by player with merged top-K Exact versus approximate tiers, histogram ranks, logical shards for growth
    Durability “Redis has persistence” Database as truth, Redis rebuildable Log-first, idempotent absolute writes, replay after failover, atomic RENAME rebuild
    Time windows A cron job that resets Key per period with TTL Event-time periods, grace window, finaliser, UNLINK, no midnight herd
    Live updates Clients poll WebSocket push Coalesced versioned deltas, gateway fan-out, targeted personal ranks, egress sizing

    Follow-up questions

    What if one real-world event changes millions of scores at once?

    That is fantasy sports: when a popular batter hits a six in an IPL final, every team that picked him gains points — possibly most of a 10-million-team contest, from one ball. Do not update teams per event. Keep the real players’ match points (22 numbers) and each team’s composition, and recompute team totals in a parallel batch every 30 to 60 seconds: 10M teams × 11 players is 110M multiply-adds, seconds of CPU across a few workers. Write the totals into fresh shard keys and swap them in with RENAME, labelled “updated 30 s ago”. The final ranking is recomputed from the database after the match, and that is the one prizes are paid on.

    What if a player submits an impossible score?

    Server authority stops the easy attacks, and per-match limits — like the minimum and maximum scores Google Play Games lets developers set — discard obvious fakes. Flag a suspected cheater in the database so rebuilds skip them, and remove them from live boards with ZREM; a shadow ban, where they still see themselves ranked, slows their next attempt. The final top 100 goes through a review queue before any prize is paid. Behavioural detection — impossible reaction times, replay analysis — is a separate anti-cheat system that sets the flag.

    What if we need boards for 200 countries and 5,000 cities?

    Each is one more key per event: six instances instead of four, so 10k events a second × 6 = 60k writes a second at peak — time to shard by player even before bursts. Memory grows by another 5 GB, since every player is in exactly one city; city boards under 128 members stay listpack-encoded and cheap. Do not derive a city board by filtering the global board on read — a filter over a sorted set is a scan.

    What if players want to see how their rank changed since yesterday?

    Extend the finaliser’s export: walk the frozen key in chunks of 10,000 with ZREVRANGE, write each player’s final rank to a columnar store keyed by player and day, and show “▲ 312 since yesterday” by comparing it with today’s live rank. For the season board, which never closes, snapshot nightly from a replica.

    What if the game runs in several regions?

    Give every board a home region. Events from other regions are mirrored to its Kafka; 100 to 200 ms of extra delay is invisible for an asynchronous event. Each region reads from local replicas, a second or so stale. If the home region fails, promote another region’s replica and replay the mirrored log from a checkpoint — the same idempotent replay as a local failover. Independent regional boards merged into a global top 100 also work; it is the shard merge again, just slower. See multi-region and active-active.

    How to say it in the interview

    “I’ll design a leaderboard for a mobile game with 50 million players, 20 million a day, about 10 thousand score events a second at the evening peak and bursts of 50 thousand, plus a live top 10 for up to two million viewers.

    Scores come only from game servers. The score API appends each event to Kafka keyed by player and returns 202. Processors apply events idempotently to a scores database, the source of truth, then write the absolute score into Redis sorted sets — one key per board and period. Because writes are absolute, replay is always safe, so after a Redis failover I rewind a few minutes and replay.

    A database can’t answer ‘what’s my rank’ cheaply: counting the players above you costs as much as your rank — 25 million entries for a median player. A sorted set is a skip list with spans plus a hash table, so the top 100, my rank and my neighbours each take about 50 steps. Ties are broken by packing points and an inverted timestamp into one double, under 2 to the 53, the largest integer a double holds exactly.

    One sorted set is one thread, so past about 50 thousand writes a second I shard each board by player into 8 keys. The top 100 is the merge of each shard’s top 100, and an exact rank is the sum of ZCOUNTs across shards — only for the top 10 thousand. Everyone else gets ‘top 24.8%’ from a score histogram.

    Days roll over at midnight IST without a reset job: the event’s end time picks the key, a ten-minute grace window takes late results, a finaliser archives the standings, and UNLINK frees memory in the background.

    For live viewers, one publisher reads the top 10 each second and publishes a versioned delta; WebSocket gateways subscribe once and fan it out. That’s one Redis read a second for two million viewers.”

    Concepts used in this design

    References and further reading

    1. Redis sorted sets (Redis documentation)redis.ioSorted sets are a skip list plus a hash table; most operations are O(log n), ZRANGE is O(log n + m), and leaderboards are the canonical use.
    2. ZADD command (Redis documentation)redis.ioScores are 64-bit doubles that hold integers exactly only up to 2^53; GT/LT options; equal scores are ordered by member bytes.
    3. ZREVRANK command (Redis documentation)redis.ioZero-based rank from the highest score in O(log N), with the optional WITHSCORE argument added in Redis 7.2.
    4. UNLINK command (Redis documentation)redis.ioDEL on a sorted set is O(M) in its size; UNLINK frees the memory in another thread.
    5. Redis source: server.h (skip list structures)github.comThe zskiplistNode layout with a span per forward pointer, ZSKIPLIST_P = 1/4 and a 32-level cap; used for the memory-per-entry arithmetic.
    6. Redis Cluster specificationredis.ioA key maps to one of 16,384 hash slots on one primary; replication is asynchronous, so a failover can lose acknowledged writes.
    7. Skip Lists: A Probabilistic Alternative to Balanced Trees (William Pugh, CACM 1990)15721.courses.cs.cmu.eduExpected search cost of log base 1/p of n, divided by p, plus 1/(1−p); the recommendation of p = 1/4 and about 1.33 pointers per element.
    8. Building a real-time gaming leaderboard with Amazon ElastiCache for Redis (AWS Database Blog)aws.amazon.comA nested SQL rank query averaging 35 seconds on 50 million rows even with indexes, versus sorted-set rank lookups.
    9. Leaderboards (Google Play Games Services documentation)developer.android.comDaily, weekly and all-time versions of every board; daily resets at UTC−7 all year; scores stored as long integers; min/max limits to discard fraudulent scores.
    10. Server-sent events (WHATWG HTML Living Standard)html.spec.whatwg.orgEventSource reconnects automatically and resumes with the Last-Event-ID header.