{"id":"design-gaming-leaderboard","title":"Design a real-time gaming leaderboard","sections":[{"id":"overview","title":"Overview","html":"<p>Online games, competitive programming sites and fitness apps use leaderboards to keep people coming back. Players expect their score and rank to change as soon as a match ends, and millions of people look at the top 100 at the same time.</p>\n<p>The design looks like \"sort players by score\", and for a few thousand players that's all it is. At tens of millions of players, the hard parts show up: answering \"what's my rank?\" without counting millions of rows, breaking ties fairly, keeping the in-memory structure durable, and deciding whether and how to shard it. The usual answer centers on <strong>Redis sorted sets</strong>, which are built on a <strong>skip list</strong>, so this chapter explains how that structure answers rank queries quickly.</p>\n<p>It builds on the primer's <a href=\"/learn/database\">database</a> and <a href=\"/learn/cache\">cache</a> chapters, and on the extra chapters on <a href=\"/learn/realtime\">real-time updates</a> and <a href=\"/learn/rate-limiting\">rate limiting</a>.</p>","headings":[]},{"id":"requirements-and-estimates","title":"Requirements and estimates","html":"<h3 id=\"requirements-and-estimates\">Requirements and estimates</h3>\n<h4 id=\"functional-requirements\">Functional requirements</h4>\n<ol>\n<li><strong>Update score:</strong> when a player finishes a match, their score goes up by the points they won.</li>\n<li><strong>Top K:</strong> show the top 10 or top 100 players.</li>\n<li><strong>My rank:</strong> a player can see their exact position, such as \"You are ranked #4,821\".</li>\n<li><strong>Around me:</strong> a player can see the players just above and below them, say 5 on each side.</li>\n<li><strong>Seasons:</strong> the leaderboard resets on a schedule (weekly, monthly or per season), and past results are kept.</li>\n</ol>\n<h4 id=\"non-functional-requirements\">Non-functional requirements</h4>\n<ol>\n<li><strong>Freshness.</strong> A finished match shows up on the leaderboard within about a second. Players notice if their rank doesn't move.</li>\n<li><strong>Throughput.</strong> Thousands of score updates a second at peak, and several times that in reads.</li>\n<li><strong>Scale.</strong> 100 million registered players, 10 million of them active on a given day.</li>\n<li><strong>Durability.</strong> A crashed server must not lose a confirmed score. The leaderboard itself can be rebuilt, as long as the match results it was built from are safe.</li>\n</ol>\n<h4 id=\"capacity-estimates\">Capacity estimates</h4>\n<p>These are assumptions for a large online game, not figures from any real one:</p>\n<ul>\n<li><strong>Updates:</strong> 10 million daily players × 10 matches each = 100 million score updates a day. 100,000,000 ÷ 86,400 s ≈ <strong>1,160 updates a second</strong> on average. With an evening peak of about 5× average, plan for roughly <strong>6,000 updates a second</strong>.</li>\n<li><strong>Reads:</strong> players check the leaderboard or their own rank after most matches, and some keep the tab open. Plan for about <strong>20,000 reads a second</strong> at peak, an assumption worth stating out loud in an interview.</li>\n<li><strong>Memory:</strong> in a Redis sorted set, each member costs its ID string, an 8-byte score, a skip list node with its forward pointers, and a hash table entry. Allow about <strong>100 bytes per player</strong>. 100 million × 100 bytes ≈ <strong>10 GB</strong>.</li>\n</ul>\n<p>10 GB fits in memory on one server, so a single global sorted set is feasible even if every registered player has a score. In practice a season's leaderboard only holds players who played that season, which is usually far fewer. Replicas are still needed for availability and to spread the reads.</p>","headings":[{"id":"requirements-and-estimates","title":"Requirements and estimates","depth":3},{"id":"functional-requirements","title":"Functional requirements","depth":4},{"id":"non-functional-requirements","title":"Non-functional requirements","depth":4},{"id":"capacity-estimates","title":"Capacity estimates","depth":4}]},{"id":"why-a-relational-database-struggles-with-ranking","title":"Why a relational database struggles with ranking","html":"<h3 id=\"why-a-relational-database-struggles-with-ranking\">Why a relational database struggles with ranking</h3>\n<p>The obvious first design is a table with an index on score:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">CREATE</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> TABLE</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\"> leaderboard</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    player_id   </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">BIGINT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> PRIMARY KEY</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    score       </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">INT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> NOT NULL</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    updated_at  </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">TIMESTAMP</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> NOT NULL</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">);</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">CREATE</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> INDEX</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\"> idx_score</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> ON</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> leaderboard (score </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">DESC</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">);</span></span></code></pre>\n<p>Now look at each query.</p>\n<ol>\n<li>\n<p><strong>Top 10</strong> is fast. The database walks the first 10 entries of the index:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> player_id, score </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> leaderboard </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">ORDER BY</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> score </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">DESC</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> LIMIT</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 10</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span></code></pre>\n</li>\n<li>\n<p><strong>A player's rank</strong> is the problem. To find the rank of a player with 1,250 points, you count everyone above them:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> COUNT</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">*</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">) </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">+</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 1</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> FROM</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> leaderboard </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">WHERE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> score </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">></span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 1250</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">;</span></span></code></pre>\n<p>A standard B-tree index doesn't store how many entries sit under each branch, so the database has to walk every index entry above 1,250. For a player in the middle of 10 million, that's about 5 million entries for one query. At thousands of rank queries a second, the database spends its time counting.</p>\n</li>\n<li>\n<p><strong>Updates</strong> are fine on their own:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">UPDATE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> leaderboard </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">SET</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> score </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> score </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">+</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 50</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> WHERE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> player_id </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> ?;</span></span></code></pre>\n<p>Each update moves one entry in the score index. A few thousand a second is a reasonable load for a well-provisioned database, but combined with the rank queries above it leaves little headroom.</p>\n</li>\n</ol>\n<p>The relational database is still the right place for the durable record of matches and scores. It just isn't a good engine for answering rank queries. For that you want a structure where rank is cheap to compute: one that knows, at each step of a search, how many elements it skipped.</p>","headings":[{"id":"why-a-relational-database-struggles-with-ranking","title":"Why a relational database struggles with ranking","depth":3}]},{"id":"skip-lists-and-redis-sorted-sets","title":"Skip lists and Redis sorted sets","html":"<h3 id=\"skip-lists-and-redis-sorted-sets\">Skip lists and Redis sorted sets</h3>\n<p>A Redis <strong>sorted set</strong> (often called a ZSET) holds unique members, each with a numeric score, kept in score order. Once a set grows past a small size (128 members by default), Redis stores it as two structures side by side:</p>\n<ol>\n<li>A <strong>hash table</strong> from member to score, so \"what's this player's score?\" takes O(1).</li>\n<li>A <strong>skip list</strong> ordered by score (and by member name for equal scores), so insert, delete and rank take O(log N) on average.</li>\n</ol>\n<p>Small sets use a compact flat encoding instead, which is faster for a handful of members.</p>\n<h4 id=\"how-a-skip-list-works\">How a skip list works</h4>\n<p>A skip list is a sorted linked list with extra \"express\" levels on top. Every element is in the bottom level. Each element is also promoted to the next level up with some fixed probability (Redis uses 1/4), so each level has roughly a quarter of the elements of the one below.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>Level 3:  HEAD ------------------------------------> 200 -> NIL</span></span>\n<span class=\"line\"><span>Level 2:  HEAD ----------------> 100 --------------> 200 -> NIL</span></span>\n<span class=\"line\"><span>Level 1:  HEAD -> 50 -> 75 ----> 100 -> 150 -> 180 -> 200 -> NIL</span></span></code></pre>\n<p>To find 180, start at the top level of HEAD. Move right while the next element is not past the target, then drop down a level and repeat. Here, level 3 jumps straight to 200, which overshoots, so drop to level 2. That moves HEAD to 100; the next step, 200, would overshoot again, so drop to level 1 and continue through 150 to 180. Each level cuts the remaining distance by about the promotion factor, so a search takes O(log N) steps on average instead of O(N) for a plain list.</p>\n<p>The part that makes rank cheap: Redis stores a <strong>span</strong> on every forward pointer, the number of bottom-level elements that pointer jumps over. While searching for a player, it adds up the spans of the pointers it follows. When it reaches the player, the sum is their rank. No counting of individual elements is needed, so rank costs the same O(log N) as a lookup.</p>\n<p>Pugh's original skip list paper (in Further reading) explains why the randomness gives balanced performance without the rotations a balanced tree needs.</p>\n<h4 id=\"leaderboard-operations-in-redis\">Leaderboard operations in Redis</h4>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span># Add points to a player (creates the member if it doesn't exist). O(log N)</span></span>\n<span class=\"line\"><span>ZINCRBY leaderboard:s2026q4 50 player_104</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span># Top 10 with scores, highest first. O(log N + M), M = number returned</span></span>\n<span class=\"line\"><span>ZRANGE leaderboard:s2026q4 0 9 REV WITHSCORES</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span># A player's 0-based rank, highest score first. O(log N)</span></span>\n<span class=\"line\"><span>ZREVRANK leaderboard:s2026q4 player_104</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span># A player's score. O(1)</span></span>\n<span class=\"line\"><span>ZSCORE leaderboard:s2026q4 player_104</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span># The 5 players above and below: with rank R from ZREVRANK,</span></span>\n<span class=\"line\"><span># ask for positions max(0, R - 5) to R + 5, e.g. for R = 4820:</span></span>\n<span class=\"line\"><span>ZRANGE leaderboard:s2026q4 4815 4825 REV WITHSCORES</span></span></code></pre>\n<p><code>ZRANGE ... REV</code> replaced <code>ZREVRANGE</code> in Redis 6.2. The older command still works and you'll see it in a lot of examples. Ranks from Redis start at 0, so add 1 before showing them.</p>\n<p>A single Redis instance runs commands on one thread, and these commands are short. One node can typically handle on the order of 100,000 simple operations a second, depending on hardware and command mix, which is far above our 6,000 writes a second.</p>","headings":[{"id":"skip-lists-and-redis-sorted-sets","title":"Skip lists and Redis sorted sets","depth":3},{"id":"how-a-skip-list-works","title":"How a skip list works","depth":4},{"id":"leaderboard-operations-in-redis","title":"Leaderboard operations in Redis","depth":4}]},{"id":"end-to-end-architecture","title":"End-to-end architecture","html":"<h3 id=\"end-to-end-architecture\">End-to-end architecture</h3>\n<figure class=\"diagram\" data-text=\"Game clients -> Load balancer; Load balancer -> API servers; API servers -> Redis replicas (reads) (top K, my rank); API servers -> Kafka score-events (1. match result); Kafka score-events -> Score workers; Score workers -> PostgreSQL (match results) (2. record match); Score workers -> Redis primary (sorted set) (3. ZINCRBY); Redis primary (sorted set) -> Redis replicas (reads) (replication)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 -15 640 340\" width=\"640\" role=\"img\" aria-label=\"Leaderboard write and read paths\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"42\" x2=\"167\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"311\" y1=\"42\" x2=\"327\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"400\" y1=\"69\" x2=\"400\" y2=\"131\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"400\" y1=\"191\" x2=\"400\" y2=\"253\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"329\" y1=\"282\" x2=\"313\" y2=\"282\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"282\" x2=\"487\" y2=\"282\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"560\" y1=\"248\" x2=\"560\" y2=\"78\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">Game clients</tspan></text><rect class=\"dg-node dg-box\" x=\"172\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"46.5\">Load balancer</tspan></text><rect class=\"dg-node dg-box\" x=\"332\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"46.5\">API servers</tspan></text><path class=\"dg-node dg-store\" d=\"M492,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"41.5\">Redis replicas</tspan><tspan x=\"560\" y=\"57.5\">(reads)</tspan></text><rect class=\"dg-node dg-queue\" x=\"332\" y=\"136\" width=\"136\" height=\"52\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"452\" y1=\"144\" x2=\"452\" y2=\"180\"></line><line class=\"dg-line\" x1=\"442\" y1=\"144\" x2=\"442\" y2=\"180\"></line><line class=\"dg-line\" x1=\"432\" y1=\"144\" x2=\"432\" y2=\"180\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"386\" y=\"158.5\">Kafka</tspan><tspan x=\"386\" y=\"174.5\">score-events</tspan></text><rect class=\"dg-node dg-box\" x=\"332\" y=\"258\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"286.5\">Score workers</tspan></text><path class=\"dg-node dg-store\" d=\"M172,258 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M172,258 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"281.5\">PostgreSQL</tspan><tspan x=\"240\" y=\"297.5\">(match results)</tspan></text><path class=\"dg-node dg-store\" d=\"M492,258 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,258 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"281.5\">Redis primary</tspan><tspan x=\"560\" y=\"297.5\">(sorted set)</tspan></text><rect class=\"dg-edge-bg\" x=\"430.6\" y=\"-11\" width=\"96.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"479\" y=\"2\" text-anchor=\"middle\">top K, my rank</text><rect class=\"dg-edge-bg\" x=\"348.5\" y=\"91\" width=\"103\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"400\" y=\"104\" text-anchor=\"middle\">1. match result</text><rect class=\"dg-edge-bg\" x=\"269.5\" y=\"229\" width=\"103\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"321\" y=\"242\" text-anchor=\"middle\">2. record match</text><rect class=\"dg-edge-bg\" x=\"443\" y=\"229\" width=\"72\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"479\" y=\"242\" text-anchor=\"middle\">3. ZINCRBY</text><rect class=\"dg-edge-bg\" x=\"520.9\" y=\"154\" width=\"78.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"560\" y=\"167\" text-anchor=\"middle\">replication</text></svg></div><figcaption>Leaderboard write and read paths</figcaption></figure>\n<h4 id=\"write-path\">Write path</h4>\n<ol>\n<li>A match ends. The <strong>game server</strong>, not the player's client, reports the result: <code>POST /api/v1/scores</code> with <code>{\"player_id\": \"p104\", \"points\": 50, \"match_id\": \"m982\"}</code>. Letting clients report their own scores invites cheating, so the API authenticates the game server and rejects anything else.</li>\n<li>The API server publishes the event to a Kafka topic, <code>score-events</code>, keyed by <code>player_id</code>. Kafka absorbs bursts and keeps the event durable even if the workers or Redis are briefly down. Keying by player keeps each player's updates in order.</li>\n<li>A score worker consumes the event and writes the match result to PostgreSQL, with a unique constraint on <code>match_id</code>. That table is the source of truth.</li>\n<li>Only if that insert succeeded does the worker run <code>ZINCRBY</code> on the Redis primary. Kafka delivers at least once, and <code>ZINCRBY</code> isn't idempotent: running it twice adds the points twice. The unique <code>match_id</code> turns a redelivered event into a no-op.</li>\n</ol>\n<p>There's still a gap: if the worker crashes after the database insert but before <code>ZINCRBY</code>, the redelivered event finds the match already recorded and skips the update. Close it by recording in the same transaction whether the Redis update was applied, and have a periodic job compare totals between PostgreSQL and Redis for recently active players. The end-to-end delay from match end to leaderboard is usually well under a second.</p>\n<h4 id=\"read-path\">Read path</h4>\n<ol>\n<li>The player opens the leaderboard: <code>GET /api/v1/leaderboard/top?limit=10</code>.</li>\n<li>The API server runs <code>ZRANGE leaderboard:s2026q4 0 9 REV WITHSCORES</code> on one of the Redis replicas.</li>\n<li>For \"around me\", it runs <code>ZREVRANK</code> to get the player's rank R, then <code>ZRANGE</code> from max(0, R − 5) to R + 5. Run both in a pipeline or a small Lua script so they see the same version of the set.</li>\n<li>It adds display names and avatars from a profile cache and returns the result.</li>\n</ol>\n<p>The top 100 changes constantly but is read by everyone. Caching that one response for a second or two in the API servers removes most of the read load from Redis, and nobody can tell the difference.</p>\n<p>Replicas lag the primary slightly, so a player who just finished a match might read their old rank from a replica. If that matters, read the player's own rank from the primary and serve the shared top-K view from replicas.</p>\n<p>To push updates to players who keep the leaderboard open, see <a href=\"/learn/realtime\">real-time updates</a>. Polling every few seconds is often good enough for a leaderboard.</p>","headings":[{"id":"end-to-end-architecture","title":"End-to-end architecture","depth":3},{"id":"write-path","title":"Write path","depth":4},{"id":"read-path","title":"Read path","depth":4}]},{"id":"breaking-ties","title":"Breaking ties","html":"<h3 id=\"breaking-ties\">Breaking ties</h3>\n<p>Two players both have 1,500 points. Who's ranked higher?</p>\n<p>Redis orders members with equal scores by member name, byte by byte. With <code>REV</code> that order is reversed too. Either way, the result depends on player IDs, which is arbitrary from the players' point of view. A common fair rule is that <strong>whoever reached the score first ranks higher</strong>.</p>\n<h4 id=\"packing-the-time-into-the-score\">Packing the time into the score</h4>\n<p>Redis scores are 64-bit floating-point numbers. A double holds every integer up to 2⁵³ (about 9 × 10¹⁵) exactly, so you can pack two values into one integer score as long as the total stays below that.</p>\n<p>Use the high part for points and the low part for how early the score was reached:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>elapsed   = seconds since the season started (must be &#x3C; 2^24, about 194 days)</span></span>\n<span class=\"line\"><span>composite = points × 2^24 + (2^24 − 1 − elapsed)</span></span>\n<span class=\"line\"><span>points    = floor(composite ÷ 2^24)</span></span></code></pre>\n<p>Higher points always win, because one point is worth more than any possible time value. Between equal points, a smaller <code>elapsed</code> gives a larger low part, so the player who got there first ranks higher. Points can go up to 2²⁹ − 1, about 537 million, before the composite passes 2⁵³.</p>\n<p>For example, Player A reaches 1,500 points 1,000 seconds into the season, and Player B reaches 1,500 points at 2,000 seconds. A's composite is 1,500 × 16,777,216 + 16,776,215, and B's is 1,500 × 16,777,216 + 16,775,215. A's is larger by 1,000, so A ranks first. The API divides by 2²⁴ and drops the remainder before showing a score.</p>\n<p>Two consequences:</p>\n<ul>\n<li><strong>Updates are no longer plain <code>ZINCRBY</code>.</strong> Adding points also has to reset the time part to the current time. The worker reads the current score, computes the new composite and writes it with <code>ZADD</code>, in a small Lua script so the read and write are atomic. Because Kafka is keyed by player, one worker handles all of a player's updates in order, so there's no race.</li>\n<li><strong>Don't encode the time as a fraction</strong>, as in points + (1 − timestamp ÷ max_timestamp). Above about a million points, a double doesn't have enough precision left in its fractional part to tell nearby timestamps apart, and ties quietly come back.</li>\n</ul>","headings":[{"id":"breaking-ties","title":"Breaking ties","depth":3},{"id":"packing-the-time-into-the-score","title":"Packing the time into the score","depth":4}]},{"id":"sharding","title":"Sharding","html":"<h3 id=\"sharding\">Sharding</h3>\n<p>A single sorted set handles our numbers, but it's worth knowing the options for when it doesn't, or when one region or game mode needs isolation.</p>\n<h4 id=\"option-1-one-primary-many-read-replicas\">Option 1: one primary, many read replicas</h4>\n<p>Keep the authoritative sorted set on one Redis primary and add replicas for reads. Our 6,000 writes a second are well within one primary's capacity, and 20,000 reads a second spread over a few replicas, plus the top-K cache, is light. This is the simplest design, and it keeps every query exact. Start here.</p>\n<h4 id=\"option-2-shard-by-score-range\">Option 2: shard by score range</h4>\n<p>Split players into sorted sets by score band, each on its own node:</p>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th>Band</th><th>Points</th><th>Node</th></tr></thead><tbody><tr><td>Bronze</td><td>0 – 1,000</td><td>1</td></tr><tr><td>Silver</td><td>1,001 – 2,500</td><td>2</td></tr><tr><td>Gold</td><td>2,501 – 5,000</td><td>3</td></tr><tr><td>Diamond</td><td>5,001 and up</td><td>4</td></tr></tbody></table></div>\n<ul>\n<li><strong>Top K</strong> reads only the highest band, assuming it holds at least K players.</li>\n<li><strong>A player's rank</strong> is the number of players in all higher bands (<code>ZCARD</code> on each, which is O(1)) plus their rank within their own band.</li>\n<li><strong>Crossing a band</strong> means removing the player from one set and adding them to another, which has to be done carefully so the player isn't briefly in both or neither.</li>\n<li><strong>Imbalance</strong> is the main drawback. Scores are rarely spread evenly, so the bottom band usually holds most players and most updates. You end up re-cutting the bands as the season goes on.</li>\n</ul>\n<h4 id=\"option-3-shard-by-player-id\">Option 3: shard by player ID</h4>\n<p>Hash <code>player_id</code> to pick one of N sorted sets. Updates are spread evenly, and each touches one shard.</p>\n<ul>\n<li><strong>A player's rank</strong> becomes a question to every shard: <code>ZCOUNT key (score +inf</code> counts players with a strictly higher score, in O(log N). Add the counts from all shards, plus 1.</li>\n<li><strong>Top K</strong> asks every shard for its top K and merges the results.</li>\n</ul>\n<p>Both reads touch every shard, but each part is cheap, and the number of shards is small. This scales writes well and avoids the imbalance of score bands.</p>","headings":[{"id":"sharding","title":"Sharding","depth":3},{"id":"option-1-one-primary-many-read-replicas","title":"Option 1: one primary, many read replicas","depth":4},{"id":"option-2-shard-by-score-range","title":"Option 2: shard by score range","depth":4},{"id":"option-3-shard-by-player-id","title":"Option 3: shard by player ID","depth":4}]},{"id":"seasons-and-archiving","title":"Seasons and archiving","html":"<h3 id=\"seasons-and-archiving\">Seasons and archiving</h3>\n<p>Most games run competitive seasons, with a fresh leaderboard each time.</p>\n<ol>\n<li><strong>One key per season,</strong> such as <code>leaderboard:s2026q4</code>. Starting a new season is just writing to a new key. Nothing is reset in place.</li>\n<li><strong>Closing a season:</strong>\n<ul>\n<li>At the cutoff, the API stops accepting results for the old season. Matches that ended before the cutoff but arrive late can be applied for a short grace period.</li>\n<li>A job reads the final standings from Redis in pages with <code>ZRANGE</code> and writes them to durable storage, such as a table in the analytics warehouse or Parquet files in object storage. PostgreSQL already has every match, so the snapshot can be checked against it.</li>\n<li>Rewards (titles, in-game items) are computed from the snapshot, not from the live key.</li>\n</ul>\n</li>\n<li><strong>Cleanup:</strong> keep the old key in Redis for a few days so \"last season\" pages stay fast, then give it a TTL or delete it with <code>UNLINK</code>, which frees memory in the background instead of blocking the server.</li>\n</ol>","headings":[{"id":"seasons-and-archiving","title":"Seasons and archiving","depth":3}]},{"id":"check-yourself","title":"Check yourself","html":"<h3 id=\"check-yourself\">Check yourself</h3>\n<details>\n<summary>Why is \"SELECT COUNT(*) + 1 FROM leaderboard WHERE score > ?\" slow at tens of millions of players, and how does a Redis sorted set answer the same question quickly?</summary>\n<p>A standard B-tree index can find where a score sits, but it doesn't know how many entries come before that point. The database has to walk every entry above the player's score and count them. For a player in the middle of 10 million, that's millions of entries for one query, and thousands of such queries a second.</p>\n<p>A Redis sorted set keeps a skip list in which every forward pointer records its span, the number of elements it jumps over. Searching for a player follows O(log N) pointers on average and adds up their spans along the way. When it reaches the player, the sum is the rank. For 10 million members that's a few dozen steps, done in memory in microseconds.</p>\n</details>\n<details>\n<summary>Two players have the same number of points. How do you rank the one who got there first higher, and why not just add a time-based fraction to the score?</summary>\n<p>Pack both values into one integer score: points × 2²⁴ plus (2²⁴ − 1 − seconds since the season started). One point outweighs any time value, so points still decide the order. Between equal points, the earlier time gives a larger number. The display score is the composite divided by 2²⁴, rounded down. The total stays below 2⁵³, so the double that Redis stores holds it exactly.</p>\n<p>A fraction such as points + (1 − timestamp ÷ max) looks simpler, but a double has only 53 bits of precision in total. The bigger the points value, the fewer bits are left for the fraction, and nearby timestamps start rounding to the same value.</p>\n</details>\n<details>\n<summary>Kafka redelivers a score event after a worker restart. What stops the player from getting the points twice?</summary>\n<p><code>ZINCRBY</code> adds the increment every time it runs, so it can't be retried safely on its own. The worker first inserts the match result into PostgreSQL, where <code>match_id</code> is unique. A redelivered event hits the unique constraint, and the worker skips the Redis update. The match table is the source of truth, and a reconciliation job compares it with Redis to catch the rare case where the insert succeeded but the Redis update didn't.</p>\n</details>\n<details>\n<summary>The Redis primary crashes. What could be lost, and how do you recover?</summary>\n<p>Replication is asynchronous, so the replica promoted by Sentinel or Redis Cluster may be missing the last few writes the primary accepted. With the append-only file set to <code>appendfsync everysec</code>, a restarted primary loses about a second of writes at most. Either way, the loss is in the leaderboard, not in the record of matches: every result is in Kafka and in PostgreSQL. Recovery replays recent events from Kafka, or in the worst case rebuilds the sorted set from the match table by summing each player's points for the season and loading them in batches with <code>ZADD</code>.</p>\n</details>\n<details>\n<summary>When would you shard by player ID instead of by score range?</summary>\n<p>When the write load outgrows one primary. Score ranges keep top-K reads on one shard, but most players and updates pile into the lower bands, and players moving between bands need careful two-step moves. Hashing by player ID spreads writes evenly and never moves players. The cost is that rank and top-K queries ask every shard (<code>ZCOUNT</code> for rank, each shard's top K for the merge), which is fine with a small number of shards.</p>\n</details>\n<h3 id=\"further-reading\">Further reading</h3>\n<ul>\n<li><a href=\"https://redis.io/docs/latest/develop/data-types/sorted-sets/\" target=\"_blank\" rel=\"noopener noreferrer\">Redis sorted sets</a>: the official guide to sorted sets, including a leaderboard example.</li>\n<li><a href=\"https://redis.io/docs/latest/commands/zrange/\" target=\"_blank\" rel=\"noopener noreferrer\">Redis ZRANGE command</a>: the unified range command with <code>REV</code>, <code>BYSCORE</code> and <code>LIMIT</code>, and its time complexity.</li>\n<li><a href=\"https://15721.courses.cs.cmu.edu/spring2018/papers/08-oltpindexes1/pugh-skiplists-cacm1990.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">Skip Lists: A Probabilistic Alternative to Balanced Trees</a>: William Pugh's 1990 paper in <em>Communications of the ACM</em> that introduced the skip list.</li>\n<li><a href=\"https://github.com/redis/redis/blob/unstable/src/t_zset.c\" target=\"_blank\" rel=\"noopener noreferrer\">Redis source: t_zset.c</a>: the sorted set implementation, including the skip list with spans that makes rank queries O(log N).</li>\n<li><a href=\"https://aws.amazon.com/blogs/database/building-a-real-time-gaming-leaderboard-with-amazon-elasticache-for-redis/\" target=\"_blank\" rel=\"noopener noreferrer\">Build a real-time gaming leaderboard with Amazon ElastiCache for Redis</a>: an AWS Database Blog walkthrough of a sorted-set leaderboard on a managed Redis service.</li>\n</ul>","headings":[{"id":"check-yourself","title":"Check yourself","depth":3},{"id":"further-reading","title":"Further reading","depth":3}]}],"sourceFiles":[]}