System Design Primer · llms.txt
Beyond the primer: design questions
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
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.
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 Redis sorted sets, which are built on a skip list, so this chapter explains how that structure answers rank queries quickly.
It builds on the primer's database and cache chapters, and on the extra chapters on real-time updates and rate limiting.
These are assumptions for a large online game, not figures from any real one:
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.
The obvious first design is a table with an index on score:
CREATE TABLE leaderboard (
player_id BIGINT PRIMARY KEY,
score INT NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_score ON leaderboard (score DESC);
Now look at each query.
Top 10 is fast. The database walks the first 10 entries of the index:
SELECT player_id, score FROM leaderboard ORDER BY score DESC LIMIT 10;
A player's rank is the problem. To find the rank of a player with 1,250 points, you count everyone above them:
SELECT COUNT(*) + 1 FROM leaderboard WHERE score > 1250;
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.
Updates are fine on their own:
UPDATE leaderboard SET score = score + 50 WHERE player_id = ?;
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.
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.
A Redis sorted set (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:
Small sets use a compact flat encoding instead, which is faster for a handful of members.
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.
Level 3: HEAD ------------------------------------> 200 -> NIL
Level 2: HEAD ----------------> 100 --------------> 200 -> NIL
Level 1: HEAD -> 50 -> 75 ----> 100 -> 150 -> 180 -> 200 -> NIL
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.
The part that makes rank cheap: Redis stores a span 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.
Pugh's original skip list paper (in Further reading) explains why the randomness gives balanced performance without the rotations a balanced tree needs.
# Add points to a player (creates the member if it doesn't exist). O(log N)
ZINCRBY leaderboard:s2026q4 50 player_104
# Top 10 with scores, highest first. O(log N + M), M = number returned
ZRANGE leaderboard:s2026q4 0 9 REV WITHSCORES
# A player's 0-based rank, highest score first. O(log N)
ZREVRANK leaderboard:s2026q4 player_104
# A player's score. O(1)
ZSCORE leaderboard:s2026q4 player_104
# The 5 players above and below: with rank R from ZREVRANK,
# ask for positions max(0, R - 5) to R + 5, e.g. for R = 4820:
ZRANGE leaderboard:s2026q4 4815 4825 REV WITHSCORES
ZRANGE ... REV replaced ZREVRANGE 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.
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.
POST /api/v1/scores with {"player_id": "p104", "points": 50, "match_id": "m982"}. Letting clients report their own scores invites cheating, so the API authenticates the game server and rejects anything else.score-events, keyed by player_id. 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.match_id. That table is the source of truth.ZINCRBY on the Redis primary. Kafka delivers at least once, and ZINCRBY isn't idempotent: running it twice adds the points twice. The unique match_id turns a redelivered event into a no-op.There's still a gap: if the worker crashes after the database insert but before ZINCRBY, 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.
GET /api/v1/leaderboard/top?limit=10.ZRANGE leaderboard:s2026q4 0 9 REV WITHSCORES on one of the Redis replicas.ZREVRANK to get the player's rank R, then ZRANGE 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.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.
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.
To push updates to players who keep the leaderboard open, see real-time updates. Polling every few seconds is often good enough for a leaderboard.
Two players both have 1,500 points. Who's ranked higher?
Redis orders members with equal scores by member name, byte by byte. With REV 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 whoever reached the score first ranks higher.
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.
Use the high part for points and the low part for how early the score was reached:
elapsed = seconds since the season started (must be < 2^24, about 194 days)
composite = points × 2^24 + (2^24 − 1 − elapsed)
points = floor(composite ÷ 2^24)
Higher points always win, because one point is worth more than any possible time value. Between equal points, a smaller elapsed 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⁵³.
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.
Two consequences:
ZINCRBY. 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 ZADD, 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.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.
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.
Split players into sorted sets by score band, each on its own node:
| Band | Points | Node |
|---|---|---|
| Bronze | 0 – 1,000 | 1 |
| Silver | 1,001 – 2,500 | 2 |
| Gold | 2,501 – 5,000 | 3 |
| Diamond | 5,001 and up | 4 |
ZCARD on each, which is O(1)) plus their rank within their own band.Hash player_id to pick one of N sorted sets. Updates are spread evenly, and each touches one shard.
ZCOUNT key (score +inf counts players with a strictly higher score, in O(log N). Add the counts from all shards, plus 1.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.
Most games run competitive seasons, with a fresh leaderboard each time.
leaderboard:s2026q4. Starting a new season is just writing to a new key. Nothing is reset in place.ZRANGE 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.UNLINK, which frees memory in the background instead of blocking the server.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.
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.
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.
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.
ZINCRBY 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 match_id 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.
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 appendfsync everysec, 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 ZADD.
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 (ZCOUNT for rank, each shard's top K for the merge), which is fine with a small number of shards.
REV, BYSCORE and LIMIT, and its time complexity.Previous: Design a social media news feed (like Facebook, Instagram, or Twitter) · Next: Design a payment system and gateway